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

> Source: <https://pournasserian.com/writing/mcp-2026-apps-building>
> Published: 2026-10-24 00:00:00+00:00

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

```
{
  "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:

``` php
@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)
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](https://pournasserian.com/writing/mcp-2026-apps):

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