# Building an MCP App: declarations, sandbox and text fallbacks

October 24, 2026 · Part 25 of 35

![Building an MCP App: declarations, sandbox and text fallbacks](https://pournasserian.com/_astro/lead.C7Ar0Xfm_1jGjtH.webp)

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](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.

## 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](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:

| 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:

```json
{
  "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](https://pournasserian.com/writing/mcp-2026-tool-definitions) 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)](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.

### 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:

```python
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:

```python
@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:

```python
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)
```

```text
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:

```mermaid
sequenceDiagram
    actor U as User
    participant V as View<br/>(iframe)
    participant H as Host
    participant S as MCP server

    U->>V: clicks a region
    V->>H: tools/call<br/>get_region_detail<br/>(visibility app)
    Note over H: check visibility,<br/>policy, optional<br/>user consent
    H->>S: tools/call<br/>get_region_detail
    S-->>H: result
    H-->>V: result
    V->>H: ui/update-<br/>model-context<br/>(selected region)
    V->>H: ui/notifications/<br/>size-changed<br/>(height 640)
    opt Post into the chat
        V->>H: ui/message<br/>(role user, text)
    end
    opt Change display mode
        V->>H: ui/request-<br/>display-mode<br/>fullscreen
        H-->>V: actual mode
    end
    H->>V: ui/resource-<br/>teardown
    V-->>H: ok (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](https://pournasserian.com/writing/mcp-2026-apps):

```ts
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:

```mermaid
flowchart TB
    subgraph page["Host page, origin A"]
        hostjs["Host app + App Bridge"]
        subgraph proxy["Proxy iframe, origin B"]
            proxyjs["Proxy: builds CSP,<br/>forwards messages"]
            subgraph inner["Inner iframe: the view"]
                app["MCP App HTML<br/>CSP from _meta.ui.csp"]
            end
        end
    end
    hostjs -->|postMessage, both ways:<br/>sandbox-proxy-ready,<br/>sandbox-resource-ready| proxyjs
    proxyjs -->|postMessage, both ways:<br/>ui methods and MCP messages| app
```

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](https://pournasserian.com/writing/mcp-2026-threat-model) 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](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.

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](https://pournasserian.com/writing/mcp-server-testing-tools-2026) 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.
