{"slug": "building-an-mcp-app-declarations-sandbox-and-text-fallbacks", "title": "Building an MCP App: declarations, sandbox and text fallbacks", "summary": "Under the 2026-07-28 Model Context Protocol specification, an MCP App is built from three declarations — a ui:// HTML resource served as text/html;profile=mcp-app, a tool whose _meta.ui.resourceUri points at that resource, and the domains the view may reach — with the host enforcing the sandbox, according to a builder's account published in October 2026. The author reports that web hosts run the view in two iframes on a different origin from the host under a Content Security Policy that can be tightened but never loosened, and that tools MUST return meaningful content even when a UI is available because some hosts will never draw the view. The advice is to ship one self-contained HTML file, have the view call the server's tools rather than its APIs, and test every tool in a text-only host.", "body_md": "# Building an MCP App: declarations, sandbox and text fallbacks\n\nAn 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.\n\nThis is part 25 of my series on what an MCP server does under the 2026-07-28 specification. [Part 24](https://pournasserian.com/writing/mcp-2026-apps) 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.\n\n## In brief\n\n1. **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.\n2. **`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.\n3. **Tools MUST return meaningful `content` even when a UI is available,** and servers SHOULD give text-only hosts a fallback.\n4. **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.\n5. **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.\n\n## What the server declares\n\n### The UI resource\n\nAn MCP App starts with a [resource](https://pournasserian.com/writing/mcp-2026-resources) whose Uniform Resource Identifier (URI) names a page. The [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) sets four rules for it:\n\n| Rule | Value | \n|---|---|\n| URI | MUST use the `ui://` scheme, which is reserved for MCP Apps | \n| Media type | MUST be `text/html;profile=mcp-app` | \n| Content | `text` (a string) or`blob` (base64), and it MUST be a valid HTML5 document | \n| Listing | Servers MAY leave UI-only resources out of `resources/list` , since hosts find them through tool metadata | \n\nThe resource’s own `_meta.ui` says what the page needs from its sandbox:\n\n| Field | What it’s for | \n|---|---|\n| `csp.connectDomains` | Origins for fetch, XMLHttpRequest and WebSocket ( `connect-src` ) | \n| `csp.resourceDomains` | Origins for scripts, styles, images, fonts and media; wildcard subdomains are allowed | \n| `csp.frameDomains` | Origins for nested iframes ( `frame-src` ,`'none'` if omitted) | \n| `csp.baseUriDomains` | Allowed base URIs ( `base-uri` ,`'self'` if omitted) | \n| `permissions` | Requests `camera` ,`microphone` ,`geolocation` or`clipboardWrite` ; hosts MAY honour them, so use feature detection | \n| `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 | \n| `prefersBorder` | Asks for ( `true` ) or against (`false` ) a visible border and background; set it explicitly, since defaults vary | \n\n### The tool link\n\nA tool points at its page with `_meta.ui.resourceUri`, and `_meta.ui.visibility` says who may call it:\n\n```\n{\n  \"name\": \"get_weather\",\n  \"description\": \"Get current weather for a location\",\n  \"inputSchema\": { \"type\": \"object\", \"properties\": { \"location\": { \"type\": \"string\" } } },\n  \"_meta\": { \"ui\": { \"resourceUri\": \"ui://weather-server/dashboard\", \"visibility\": [\"model\", \"app\"] } }\n}\n```\n\n| `visibility` | Effect | \n|---|---|\n| `[\"model\", \"app\"]` , the default | The agent sees it and can call it, and so can the app | \n| `[\"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 | \n| `[\"model\"]` | Hosts MUST reject a `tools/call` for it from the app | \n\nThe older flat key, `_meta[\"ui/resourceUri\"]`, is deprecated. Use `_meta.ui.resourceUri`.\n\n### Results for two audiences\n\nA UI-enabled tool’s result has two readers, as [part 8](https://pournasserian.com/writing/mcp-2026-tool-definitions) set out:\n\n| Field | Who it’s for | \n|---|---|\n| `content` | The model, and text-only hosts | \n| `structuredContent` | The view; under the MCP Apps convention it isn’t added to the model’s context | \n| `_meta` | Neither: metadata such as timestamps and versions | \n\nTools MUST return meaningful `content` even when a UI is available, and servers SHOULD provide text-only fallback behaviour for every UI-enabled tool.\n\nKeeping `structuredContent` from the model is only a convention: [Claude Code and its Agent software development kit (SDK)](https://code.claude.com/docs/en/agent-sdk/custom-tools) 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.\n\n### In Python\n\nThe official Python SDK, `mcp` 2.3.0, has MCP Apps built in. First the page, declaring the one origin its script comes from:\n\n``` python\nfrom mcp.server import MCPServer\nfrom mcp.server.apps import Apps, ResourceCsp, client_supports_apps\nfrom mcp.server.mcpserver import Context\nfrom mcp.types import CallToolResult, TextContent\n\nSALES = {\"north\": 1200, \"south\": 950}\nUI = \"ui://sales/dashboard.html\"\napps = Apps()\n\napps.add_html_resource(\n    UI,\n    \"\"\"<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>Sales</title></head>\n<body><div id=\"app\">Loading sales...</div>\n<script type=\"module\" src=\"https://cdn.example.com/sales-view.js\"></script>\n</body></html>\"\"\",\n    csp=ResourceCsp(resource_domains=[\"https://cdn.example.com\"]),\n    prefers_border=True,\n)\n```\n\nThen 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:\n\n``` php\n@apps.tool(resource_uri=UI)\ndef get_sales(ctx: Context) -> CallToolResult:\n    \"\"\"Sales by region for this quarter.\"\"\"\n    total = sum(SALES.values())\n    if not client_supports_apps(ctx):\n        lines = [f\"{region}: {amount}\" for region, amount in SALES.items()]\n        text = f\"Sales this quarter: {total}.\\n\" + \"\\n\".join(lines)\n        return CallToolResult(content=[TextContent(type=\"text\", text=text)])\n    summary = f\"Sales this quarter: {total}, shown in the dashboard.\"\n    return CallToolResult(\n        content=[TextContent(type=\"text\", text=summary)],\n        structured_content={\"regions\": SALES},\n    )\n\n@apps.tool(resource_uri=UI, visibility=[\"app\"])\ndef get_region_detail(region: str) -> str:\n    \"\"\"Details for one region, for the dashboard's click handler.\"\"\"\n    return f\"{region}: {SALES[region]}\"\n\nmcp = MCPServer(\"sales\", extensions=[apps])\n```\n\nA 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:\n\n``` python\nimport anyio\nfrom mcp import Client\nfrom mcp.client import advertise\n\nAPPS = advertise(\"io.modelcontextprotocol/ui\", {\"mimeTypes\": [\"text/html;profile=mcp-app\"]})\n\nasync def main():\n    async with Client(mcp, extensions=[APPS]) as client:\n        result = await client.call_tool(\"get_sales\", {})\n        print(\"apps host:\", result.content[0].text, result.structured_content)\n    async with Client(mcp) as client:\n        result = await client.call_tool(\"get_sales\", {})\n        print(\"text host:\", repr(result.content[0].text), result.structured_content)\n\nanyio.run(main)\napps host: Sales this quarter: 2150, shown in the dashboard. {'regions': {'north': 1200, 'south': 950}}\ntext host: 'Sales this quarter: 2150.\\nnorth: 1200\\nsouth: 950' None\n```\n\nIn 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.\n\nThe 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.\n\n## What the view does\n\nThe view is the page, and it talks only to the host:\n\n| Step | What the view does | \n|---|---|\n| Handshake | Sends `ui/initialize` with`appCapabilities` , including the`availableDisplayModes` it supports, then`ui/notifications/initialized` . The host must not message it before that | \n| 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 | \n| 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 | \n| 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 | \n| 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 | \n| Teardown | Answer `ui/resource-teardown` once state is saved | \n\nA click can then call an app-only tool. The host checks visibility and policy, and may ask the user, before forwarding it:\n\nThe 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](https://pournasserian.com/writing/mcp-2026-apps):\n\n``` js\nimport { App } from \"@modelcontextprotocol/ext-apps\";\n\nconst root = document.getElementById(\"app\")!;\nconst detailLine = document.createElement(\"p\");\nconst app = new App(\n  { name: \"Sales dashboard\", version: \"1.0.0\" },\n  { availableDisplayModes: [\"inline\", \"fullscreen\"] },\n);\n\nfunction render(regions: Record<string, number>) {\n  root.replaceChildren(detailLine);\n  for (const [region, amount] of Object.entries(regions)) {\n    const button = document.createElement(\"button\");\n    button.textContent = `${region}: ${amount}`;\n    button.onclick = async () => {\n      const detail = await app.callServerTool({ name: \"get_region_detail\", arguments: { region } });\n      const [block] = detail.content;\n      if (detail.isError || block?.type !== \"text\") return;\n      detailLine.textContent = block.text;\n      await app.updateModelContext({\n        content: [{ type: \"text\", text: `The user selected the ${region} region.` }],\n      });\n    };\n    root.append(button);\n  }\n}\n\n// Register handlers before connecting, so the tool result isn't missed.\napp.addEventListener(\"toolresult\", (result) => {\n  const data = result.structuredContent as { regions?: Record<string, number> } | undefined;\n  if (data?.regions) render(data.regions);\n});\napp.onteardown = async () => ({});\n\nawait app.connect();\n```\n\n## How hosts contain it\n\nHosts contain the view in three ways:\n\n| Host duty | What it means | \n|---|---|\n| 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 | \n| 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 | \n| Gatekeeping | Hosts may restrict which tools the view can call, disable link opening, or require the user’s consent before forwarding a `tools/call` | \n\nOn the web, the view sits in an inner iframe inside a sandbox proxy on a different origin from the host:\n\nThe security model, in short:\n\n| Element | What it says | \n|---|---|\n| Assumes | The server may be malicious: harmful HTML, sandbox escapes, unauthorized tool execution, data exfiltration, phishing | \n| 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 | \n| Remaining risks | Misleading content, so hosts should clearly mark sandbox boundaries; resource consumption | \n\n[Part 21](https://pournasserian.com/writing/mcp-2026-threat-model) places UI phishing in the wider threat model.\n\n## Where it runs, and how I’d build one\n\nIn October 2026, the [official extension support matrix](https://modelcontextprotocol.io/extensions/client-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](https://code.claude.com/docs/en/mcp) runs in a terminal, so it filters `ui://` resources out of its resource suggestions instead of rendering them.\n\nThe rules above are the specification’s. My advice goes further:\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n\nBefore you submit an app, try it outside a host. [My survey of MCP testing tools](https://pournasserian.com/writing/mcp-server-testing-tools-2026) lists the emulators, previews and audits for that.\n\n## Method and caveats\n\n- 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.\n- 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.\n- 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.\n- 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.", "url": "https://wpnews.pro/news/building-an-mcp-app-declarations-sandbox-and-text-fallbacks", "canonical_source": "https://pournasserian.com/writing/mcp-2026-apps-building", "published_at": "2026-10-24 00:00:00+00:00", "updated_at": "2026-10-24 12:43:12.317307+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "developer-tools", "ai-tools"], "entities": ["Model Context Protocol", "MCP Apps specification", "MCP Apps"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-an-mcp-app-declarations-sandbox-and-text-fallbacks", "markdown": "https://wpnews.pro/news/building-an-mcp-app-declarations-sandbox-and-text-fallbacks.md", "text": "https://wpnews.pro/news/building-an-mcp-app-declarations-sandbox-and-text-fallbacks.txt", "jsonld": "https://wpnews.pro/news/building-an-mcp-app-declarations-sandbox-and-text-fallbacks.jsonld"}}