Building an MCP App: declarations, sandbox and text fallbacks

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

  1. The server declares and the host enforces. It declares a ui:// HyperText Markup Language (HTML) resource served as text/html;profile=mcp-app, a tool whose _meta.ui.resourceUri points at it, and the domains its view may reach.
  2. visibility decides who may call a tool, so a tool marked ["app"] serves the view’s buttons without ever reaching the model’s tool list.
  3. Tools MUST return meaningful content even when a UI is available, and servers SHOULD give text-only hosts a fallback.
  4. 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.
  5. 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:

RuleValue
URIMUST use the ui:// scheme, which is reserved for MCP Apps
Media typeMUST be text/html;profile=mcp-app
Contenttext (a string) or blob (base64), and it MUST be a valid HTML5 document
ListingServers 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:

FieldWhat it’s for
csp.connectDomainsOrigins for fetch, XMLHttpRequest and WebSocket (connect-src)
csp.resourceDomainsOrigins for scripts, styles, images, fonts and media; wildcard subdomains are allowed
csp.frameDomainsOrigins for nested iframes (frame-src, 'none' if omitted)
csp.baseUriDomainsAllowed base URIs (base-uri, 'self' if omitted)
permissionsRequests camera, microphone, geolocation or clipboardWrite; hosts MAY honour them, so use feature detection
domainRequests a dedicated sandbox origin, useful for OAuth callbacks, cross-origin resource sharing (CORS) and API-key allowlists; the format depends on the host
prefersBorderAsks for (true) or against (false) a visible border and background; set it explicitly, since defaults vary

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"] } }
}
visibilityEffect
["model", "app"], the defaultThe 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:

FieldWho it’s for
contentThe model, and text-only hosts
structuredContentThe view; under the MCP Apps convention it isn’t added to the model’s context
_metaNeither: 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:

StepWhat the view does
HandshakeSends ui/initialize with appCapabilities, including the availableDisplayModes it supports, then ui/notifications/initialized. The host must not message it before that
ThemingHosts 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
SizingEach 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 modesinline, 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 modelui/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
TeardownAnswer 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:

User → View (iframe): clicks a region; View (iframe) → Host: tools/call get_region_detail (visibility app); Note (Host): check visibility, policy, optional user consent; Host → MCP server: tools/call get_region_detail; MCP server → Host: result; Host → View (iframe): result; View (iframe) → Host: ui/update- model-context (selected region); View (iframe) → Host: ui/notifications/ size-changed (height 640); opt: Post into the chat; View (iframe) → Host: ui/message (role user, text); opt: Change display mode; View (iframe) → Host: ui/request- display-mode fullscreen; Host → View (iframe): actual mode; Host → View (iframe): ui/resource- teardown; View (iframe) → Host: ok (state saved)MCP serverHostView(iframe)check visibility,policy, optionaluser consentopt[Post into the chat]opt[Change displaymode]Userclicks a regiontools/callget_region_detail(visibility app)tools/callget_region_detailresultresultui/update-model-context(selected region)ui/notifications/size-changed(height 640)ui/message(role user, text)ui/request-display-modefullscreenactual modeui/resource-teardownok (state saved)

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 dutyWhat it means
Two iframes on the webThe 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 CSPIf 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
GatekeepingHosts 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:

Host page, origin A: Proxy iframe, origin B, Host app + App Bridge; Proxy iframe, origin B: Inner iframe: the view, Proxy: builds CSP, forwards messages; Inner iframe: the view: MCP App HTML CSP from _meta.ui.csp; Host app + App Bridge → Proxy: builds CSP, forwards messages: postMessage, both ways: sandbox-proxy-ready, sandbox-resource-ready; Proxy: builds CSP, forwards messages → MCP App HTML CSP from _meta.ui.csp: postMessage, both ways: ui methods and MCP messagesHost page, origin AProxy iframe, origin BInner iframe: the viewHost app + App BridgeProxy: builds CSP,forwards messagesMCP App HTMLCSP from _meta.ui.csppostMessage, both ways:sandbox-proxy-ready,sandbox-resource-readypostMessage, both ways:ui methods and MCP messages

The security model, in short:

ElementWhat it says
AssumesThe server may be malicious: harmful HTML, sandbox escapes, unauthorized tool execution, data exfiltration, phishing
MitigationsMandatory iframe sandboxing; JSON-RPC 2.0 traffic that can be audited; review of predeclared templates before anything runs (hashes, allowlists, blocklists); CSP enforcement
Remaining risksMisleading 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-apps provides it, with React hooks and an App Bridge for hosts, but you can speak the postMessage protocol 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 call connectDomains; 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-eval script 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 mcp 2.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 and cdn.example.com are placeholders.
  • The TypeScript view was type-checked with tsc --noEmit (TypeScript 7.0.2, @modelcontextprotocol/ext-apps 2.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 structuredContent to the model), this article follows it.