MCP Apps
Attach an interactive UI to an agent and an mcp deployment
serves it to any host that supports the MCP Apps extension
(SEP-1865) — Claude,
ChatGPT, Copilot. The host renders your HTML inline in a sandboxed iframe instead
of plain text; a host that doesn't support it ignores the UI and shows the reply.
You write standard MCP Apps HTML (the @modelcontextprotocol/ext-apps App
class, the ui:// scheme, the text/html;profile=mcp-app MIME) — Ingram Cloud
is a conforming MCP Apps server. Templates are tenant-authored and
version-pinned; Ingram never generates UI at runtime.
The pieces
- A template is an HTML bundle attached to an agent under a
name. Its bytes live in blob storage; the agent carries the reference. - Publishing an agent version freezes its templates into the immutable snapshot, so a rendered panel is reproducible per version.
- A smith running that agent (over its
mcpdeployment) is what the host talks to; the panel it renders is byte-identical for every caller.
Author a template
Upload the bundle as multipart — the HTML file part plus a JSON metadata
sidecar. Uploads land on the agent's draft; publish a version to freeze it.
# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/agents/agt_…/ui \
-H "Authorization: Bearer $IC_TOKEN" \
-H "IC-Api-Version: 2026-05-01" \
-F 'file=@dashboard.html;type=text/html' \
-F 'metadata={"name":"dashboard","csp":{"connectDomains":["https://api.example.com"]}}'
# → 200 { "name": "dashboard", "content_hash": "…", "csp": { … } }
metadata fields:
name— unique per agent ([a-z0-9_-]); re-uploading the same name replaces it.csp— the MCP Apps_meta.ui.cspobject (connectDomains,resourceDomains,frameDomains,baseUriDomains), the origins the sandboxed iframe may reach. Omit it and the deny-by-default sandbox allows only a self-contained bundle.permissions— the_meta.ui.permissionsmap the template requests (e.g.{ "camera": {} }).prefers_border—_meta.ui.prefersBorder: whether the host draws its own card around the panel. Omit it to take the host's default (Claude: borderless on web, bordered on mobile); setfalsefor a panel that blends into the conversation.domain—_meta.ui.domain: a stable sandbox origin, needed only when the panel runs its own OAuth flow or your API allowlists it byOrigin. The format is the host's; for Claude it is the first 32 hex characters of the SHA-256 of your deployment's MCP URL followed by.claudemcpcontent.com.tool— present only for a typed app-tool (below).
Every _meta.ui field is echoed to the host verbatim on both resources/list
and resources/read, so a host can review the sandbox before it fetches the
bundle.
List, fetch, and remove templates on the draft:
# Authorization: tenant-admin token (server-side only)
curl https://api.cloud.ingram.tech/v1/agents/agt_…/ui \
-H "Authorization: Bearer $IC_TOKEN" -H "IC-Api-Version: 2026-05-01"
# → { "data": [ { "name": "dashboard", … } ] }
# The bundle's bytes, tagged with a strong ETag of its content_hash. Send it
# back as If-None-Match and an unchanged bundle answers 304:
curl https://api.cloud.ingram.tech/v1/agents/agt_…/ui/dashboard/content \
-H "Authorization: Bearer $IC_TOKEN" -H "IC-Api-Version: 2026-05-01"
curl -X DELETE https://api.cloud.ingram.tech/v1/agents/agt_…/ui/dashboard \
-H "Authorization: Bearer $IC_TOKEN" -H "IC-Api-Version: 2026-05-01"
The internal blob key is never returned. These endpoints ride the same
runs:read / runs:write scopes and tenant-admin gate as the rest of
/v1/agents — a smith-scoped token can't touch them.
Then publish so the template is live for the deployment:
# Authorization: tenant-admin token (server-side only)
curl -X POST https://api.cloud.ingram.tech/v1/agents/agt_…/versions \
-H "Authorization: Bearer $IC_TOKEN" -H "IC-Api-Version: 2026-05-01" \
-H "Content-Type: application/json" -d '{ "note": "with dashboard UI" }'
How it renders
Deploy the agent as an mcp server and
connect a host. A host renders the template its tool
descriptor links (_meta.ui.resourceUri on tools/list), so which panel shows
is decided by which tool the host calls:
- Bound to
ask— name a templateaskand everyaskreply renders inside it. - Typed app-tools — give a template a
tool({ description, input_schema, instruction, mutating, visibility }). The deployment surfaces one MCP tool per app-tool, each linked to its own template; the host's model routes to the right one, and the smith runs the template's scopedinstructionwith the host-supplied arguments. Amutatingtool carries the standarddestructiveHintannotation, so the host asks the user before a write; reads run freely.
The panel receives the run's result over the standard MCP Apps channel:
content is the smith's text reply, structuredContent the JSON to render.
The smith fills structuredContent by calling the built-in render_app tool
(enabled once the agent has a template) with { template, data } — data
lands in the panel when template is the one the called tool links. Without a
render_app call the panel gets { "text": … }, the reply. Keep the payload
lean: Claude spills results over ~150k characters to its sandbox filesystem and
the panel never hydrates; paginate, and let the panel fetch more.
Tools the panel calls
A rendered panel can call the deployment's tools itself through the host's
tools/call proxy — a refresh, the next page, a write. Each call runs a smith
turn; a write that needs approval pauses the run on the standard
approval channel. Set tool.visibility to ["app"] for
a tool only the panel should call: the host keeps it out of its model's tool
list, and the descriptor says so in _meta.ui.visibility.
Hosts
Any host in the MCP Apps client matrix renders the same deployment; the two you meet most:
- Claude — web, desktop, iOS and Android (not Claude Code). Add the
deployment as a custom connector; users approve the panel
once ("Always allow").
frameDomainsis restricted pending Claude's security review; mobile grants no camera, microphone or location. Follow Claude's design guidelines for style tokens and display modes. - ChatGPT — implements the same standard:
_meta.ui.resourceUri, the profile MIME,csp,prefersBorder,domain, and theui/*bridge. Add the deployment as a developer-mode connector and the panel renders as-is; theopenai/*aliases andwindow.openaiare ChatGPT's compatibility layer for older apps, not something you author against. Capabilities only ChatGPT offers (checkout, file upload, modals) have no standard expression yet and are not exposed.
In your own chat
Templates also render in your own app. An app-embedded chat riding
@ingram-cloud/ai-sdk can render the same
bundle: render_app arrives as a standard tool call carrying { template, data } — there, any template it names renders — and …/ui/{name}/content serves the
canonical bytes. One template covers MCP hosts, your own app, and text
degradation, with no second copy to drift.
Degradation
Only MCP hosts render ui:// panels. On a chat channel (Slack, Telegram, email)
or any host without the extension, the smith's text reply stands on its own — so
write a reply that reads well without the UI.
In the console
An agent's Deploy tab lists its apps under MCP Apps, uploads new
bundles, sets connect origins, the border preference and app-only tools, and
previews a template. The
preview is an MCP Apps host: it answers the bundle's ui/initialize, then
delivers tool-input and a sample tool-result, so a bundle built on
@modelcontextprotocol/ext-apps renders exactly as it connects in Claude or
ChatGPT. permissions and domain are set through the API or
Pulumi.