cd /news/agent-protocols/building-an-mcp-app-declarations-san… · home › topics › agent-protocols › article
[ARTICLE · art-156772] src=pournasserian.com ↗ pub= topic=agent-protocols verified=true sentiment=· neutral

Building an MCP App: declarations, sandbox and text fallbacks

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.

by read11 min views2 publishedOct 24, 2026
Building an MCP App: declarations, sandbox and text fallbacks
Image: Pournasserian (auto-discovered)

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 aui:// HyperText Markup Language (HTML) resource served astext/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:

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) orblob (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 orclipboardWrite ; 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"> 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 withappCapabilities , including theavailableDisplayModes it supports, thenui/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 orpip (picture-in-picture), asked for withui/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-apps provides it, with React hooks and an App Bridge for hosts, but you can speak thepostMessage 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 needsresourceDomains , 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 awasm-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 andcdn.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.
── more in #agent-protocols 4 stories · sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-an-mcp-app-…] indexed:0 read:11min 2026-10-24 · —