Building an MCP App: declarations, sandbox and text fallbacks

An MCP App is an interactive view that a Model Context Protocol (MCP) server puts into the conversation. Building one turned out to be mostly declarations: a page, a tool that points at it, and the few domains the page may reach. The host does the enforcing. What I’d hold onto is the plain part: a tool with a view still has to answer in text, because some hosts will never draw it.
This is part 25 of my series on what an MCP server does under the 2026-07-28 specification. Part 24 showed how an app’s user interface (UI) reaches the screen, and this one is the builder’s side. The facts are as I read them in October 2026.
In brief
- The server declares and the host enforces. It declares a
ui://HyperText Markup Language (HTML) resource served astext/html;profile=mcp-app, a tool whose_meta.ui.resourceUripoints at it, and the domains its view may reach. visibilitydecides who may call a tool, so a tool marked["app"]serves the view’s buttons without ever reaching the model’s tool list.- Tools MUST return meaningful
contenteven when a UI is available, and servers SHOULD give text-only hosts a fallback. - Web hosts run the view in two iframes, on a different origin from the host, under a Content Security Policy (CSP) that can be tightened but never loosened.
- My advice: ship one self-contained HTML file, have the view call your tools rather than your application programming interfaces (APIs), and test every tool in a text-only host.
What the server declares
The UI resource
An MCP App starts with a resource whose Uniform Resource Identifier (URI) names a page. The MCP Apps specification sets four rules for it:
| Rule | Value |
|---|---|
| URI | MUST use the ui:// scheme, which is reserved for MCP Apps |
| Media type | MUST be text/html;profile=mcp-app |
| Content | text (a string) or blob (base64), and it MUST be a valid HTML5 document |
| Listing | Servers MAY leave UI-only resources out of resources/list, since hosts find them through tool metadata |
The resource’s own _meta.ui says what the page needs from its sandbox:
| Field | What it’s for |
|---|---|
csp.connectDomains | Origins for fetch, XMLHttpRequest and WebSocket (connect-src) |
csp.resourceDomains | Origins for scripts, styles, images, fonts and media; wildcard subdomains are allowed |
csp.frameDomains | Origins for nested iframes (frame-src, 'none' if omitted) |
csp.baseUriDomains | Allowed base URIs (base-uri, 'self' if omitted) |
permissions | Requests camera, microphone, geolocation or clipboardWrite; hosts MAY honour them, so use feature detection |
domain | Requests a dedicated sandbox origin, useful for OAuth callbacks, cross-origin resource sharing (CORS) and API-key allowlists; the format depends on the host |
prefersBorder | Asks for (true) or against (false) a visible border and background; set it explicitly, since defaults vary |
The tool link
A tool points at its page with _meta.ui.resourceUri, and _meta.ui.visibility says who may call it:
{
"name": "get_weather",
"description": "Get current weather for a location",
"inputSchema": { "type": "object", "properties": { "location": { "type": "string" } } },
"_meta": { "ui": { "resourceUri": "ui://weather-server/dashboard", "visibility": ["model", "app"] } }
}
visibility | Effect |
|---|---|
["model", "app"], the default | The agent sees it and can call it, and so can the app |
["app"] | Hidden from the agent: hosts MUST NOT include it in the model’s tool list, but the app can call it on the same server connection. Use it for refresh buttons and form submits |
["model"] | Hosts MUST reject a tools/call for it from the app |
The older flat key, _meta["ui/resourceUri"], is deprecated. Use _meta.ui.resourceUri.
Results for two audiences
A UI-enabled tool’s result has two readers, as part 8 set out:
| Field | Who it’s for |
|---|---|
content | The model, and text-only hosts |
structuredContent | The view; under the MCP Apps convention it isn’t added to the model’s context |
_meta | Neither: metadata such as timestamps and versions |
Tools MUST return meaningful content even when a UI is available, and servers SHOULD provide text-only fallback behaviour for every UI-enabled tool.
Keeping structuredContent from the model is only a convention: Claude Code and its Agent software development kit (SDK) pass it to the model in place of the text blocks of content, and ChatGPT lets the model see it. My advice, as in part 8: write each half to stand on its own.
In Python
The official Python SDK, mcp 2.3.0, has MCP Apps built in. First the page, declaring the one origin its script comes from:
from mcp.server import MCPServer
from mcp.server.apps import Apps, ResourceCsp, client_supports_apps
from mcp.server.mcpserver import Context
from mcp.types import CallToolResult, TextContent
SALES = {"north": 1200, "south": 950}
UI = "ui://sales/dashboard.html"
apps = Apps()
apps.add_html_resource(
UI,
"""<!doctype html>
<html><head><meta charset="utf-8"><title>Sales</title></head>
<body><div id="app">Loading sales...</div>
<script type="module" src="https://cdn.example.com/sales-view.js"></script>
</body></html>""",
csp=ResourceCsp(resource_domains=["https://cdn.example.com"]),
prefers_border=True,
)
Then the tool linked to the page. When client_supports_apps(ctx) is False (the client didn’t declare Apps support), it puts every figure in its text. A second, app-only tool serves the view’s clicks:
@apps.tool(resource_uri=UI)
def get_sales(ctx: Context) -> CallToolResult:
"""Sales by region for this quarter."""
total = sum(SALES.values())
if not client_supports_apps(ctx):
lines = [f"{region}: {amount}" for region, amount in SALES.items()]
text = f"Sales this quarter: {total}.\n" + "\n".join(lines)
return CallToolResult(content=[TextContent(type="text", text=text)])
summary = f"Sales this quarter: {total}, shown in the dashboard."
return CallToolResult(
content=[TextContent(type="text", text=summary)],
structured_content={"regions": SALES},
)
@apps.tool(resource_uri=UI, visibility=["app"])
def get_region_detail(region: str) -> str:
"""Details for one region, for the dashboard's click handler."""
return f"{region}: {SALES[region]}"
mcp = MCPServer("sales", extensions=[apps])
A client declares Apps support by advertising the extension with its media type. The in-memory Client below connects once that way, like an Apps host, and once without, like a text-only host:
import anyio
from mcp import Client
from mcp.client import advertise
APPS = advertise("io.modelcontextprotocol/ui", {"mimeTypes": ["text/html;profile=mcp-app"]})
async def main():
async with Client(mcp, extensions=[APPS]) as client:
result = await client.call_tool("get_sales", {})
print("apps host:", result.content[0].text, result.structured_content)
async with Client(mcp) as client:
result = await client.call_tool("get_sales", {})
print("text host:", repr(result.content[0].text), result.structured_content)
anyio.run(main)
apps host: Sales this quarter: 2150, shown in the dashboard. {'regions': {'north': 1200, 'south': 950}}
text host: 'Sales this quarter: 2150.\nnorth: 1200\nsouth: 950' None
In an Apps host the text is shorter but still answers with the total, and the view below tells the model which region the user picks.
The SDK writes _meta.ui on the tools and the page for you, and serves the page as text/html;profile=mcp-app. It raises a ValueError for a URI that isn’t ui://, and building the MCPServer raises one if a tool points at a page you didn’t register. It still lists the app-only tool in tools/list: hiding it from the model is the host’s job.
What the view does
The view is the page, and it talks only to the host:
| Step | What the view does |
|---|---|
| Handshake | Sends ui/initialize with appCapabilities, including the availableDisplayModes it supports, then ui/notifications/initialized. The host must not message it before that |
| Theming | Hosts may pass standard Cascading Style Sheets (CSS) variables, such as --color-background-primary and --font-sans, plus font CSS. Define fallbacks, since a host may send some, none or all of them |
| Sizing | Each dimension is fixed, flexible up to a maximum, or unbounded. For flexible sizes, send ui/notifications/size-changed; the app SDK below does it for you with a ResizeObserver |
| Display modes | inline, fullscreen or pip (picture-in-picture), asked for with ui/request-display-mode. Handle a reply with a different mode than you asked for |
| Talking to the model | ui/message adds a message to the conversation, which usually triggers a model turn. ui/update-model-context silently replaces the app’s context for future turns; each call overwrites the last, and hosts forward only the latest before the next user message |
| Teardown | Answer ui/resource-teardown once state is saved |
A click can then call an app-only tool. The host checks visibility and policy, and may ask the user, before forwarding it:
The view runs in the browser, so here it’s TypeScript. The App class of @modelcontextprotocol/ext-apps 2.0.3 does the handshake in connect() and turns on the size notifications. The tool’s result arrives as the toolresult event, the ui/notifications/tool-result message of part 24:
import { App } from "@modelcontextprotocol/ext-apps";
const root = document.getElementById("app")!;
const detailLine = document.createElement("p");
const app = new App(
{ name: "Sales dashboard", version: "1.0.0" },
{ availableDisplayModes: ["inline", "fullscreen"] },
);
function render(regions: Record<string, number>) {
root.replaceChildren(detailLine);
for (const [region, amount] of Object.entries(regions)) {
const button = document.createElement("button");
button.textContent = `${region}: ${amount}`;
button.onclick = async () => {
const detail = await app.callServerTool({ name: "get_region_detail", arguments: { region } });
const [block] = detail.content;
if (detail.isError || block?.type !== "text") return;
detailLine.textContent = block.text;
await app.updateModelContext({
content: [{ type: "text", text: `The user selected the ${region} region.` }],
});
};
root.append(button);
}
}
// Register handlers before connecting, so the tool result isn't missed.
app.addEventListener("toolresult", (result) => {
const data = result.structuredContent as { regions?: Record<string, number> } | undefined;
if (data?.regions) render(data.regions);
});
app.onteardown = async () => ({});
await app.connect();
How hosts contain it
Hosts contain the view in three ways:
| Host duty | What it means |
|---|---|
| Two iframes on the web | The view is wrapped in a sandbox proxy on a different origin from the host. The proxy loads the HTML with a CSP built from the declared domains, blocks object-src, and forwards messages both ways |
| A strict CSP | If csp is omitted, hosts apply a restrictive default with no outbound connections. Hosts may tighten it further but MUST NOT allow undeclared domains, and SHOULD log CSP configurations for audit |
| Gatekeeping | Hosts may restrict which tools the view can call, disable link opening, or require the user’s consent before forwarding a tools/call |
On the web, the view sits in an inner iframe inside a sandbox proxy on a different origin from the host:
The security model, in short:
| Element | What it says |
|---|---|
| Assumes | The server may be malicious: harmful HTML, sandbox escapes, unauthorized tool execution, data exfiltration, phishing |
| Mitigations | Mandatory iframe sandboxing; JSON-RPC 2.0 traffic that can be audited; review of predeclared templates before anything runs (hashes, allowlists, blocklists); CSP enforcement |
| Remaining risks | Misleading content, so hosts should clearly mark sandbox boundaries; resource consumption |
Part 21 places UI phishing in the wider threat model.
Where it runs, and how I’d build one
In October 2026, the official extension support matrix lists MCP Apps support in Claude (web) and Claude Desktop, ChatGPT, VS Code GitHub Copilot, Microsoft 365 Copilot, Cursor, Goose, Postman, MCPJam, Archestra.AI and PostHog Code. Claude Code runs in a terminal, so it filters ui:// resources out of its resource suggestions instead of rendering them.
The rules above are the specification’s. My advice goes further:
- The app SDK is a convenience.
@modelcontextprotocol/ext-appsprovides it, with React hooks and an App Bridge for hosts, but you can speak thepostMessageprotocol directly. Starter templates exist for React, Vue, Svelte, Preact, Solid and vanilla JavaScript. - Ship one self-contained HTML file, bundled with Vite and a single-file plugin. Every external script needs
resourceDomains, and every API callconnectDomains; my sample loads its script from another origin only to show the declaration. - Call tools, not APIs. The host relays the view’s tool calls over the server’s existing authorized connection, which avoids CORS, CSP and auth problems.
- Prefer small frameworks, such as Svelte or vanilla views. Heavy runtimes such as Blazor WebAssembly are likely a poor fit for inline widgets: they’re large to download, and compiling WebAssembly needs a
wasm-unsafe-evalscript source the spec’s default CSP doesn’t include. - Degrade cleanly. Test every UI-enabled tool in a text-only host. If the answer isn’t useful as plain text, the tool isn’t finished.
- Pick the right jobs: exploring complex data, configuration with many interdependent options, rich media viewers, live monitoring, and multi-step workflows such as approvals or triage.
Before you submit an app, try it outside a host. My survey of MCP testing tools lists the emulators, previews and audits for that.
Method and caveats
- Built from my guide’s chapter on MCP Apps, written against the 2026-07-28 specification and the MCP Apps specification of 2026-01-26. Host support is as of October 2026.
- The Python sample ran against
mcp2.3.0 on 9 October 2026 and printed the output above. The notes on what the SDK writes and checks come from the package’s source and a short probe. The sales figures andcdn.example.comare placeholders. - The TypeScript view was type-checked with
tsc --noEmit(TypeScript 7.0.2,@modelcontextprotocol/ext-apps2.0.3), skipping library checks because one dependency expects Node’s types, but not run in a host. - Where the hosts’ documentation, read on 8 October 2026, is more precise than my notes (some hosts do pass
structuredContentto the model), this article follows it.