MCP Apps With Safe Interactive UI
Sandbox embedded UI, negotiate host capabilities, preserve accessible fallbacks, and audit every call that leaves the app.
MCP Apps make a tool result interactive, but the embedded interface must never inherit the host's authority by proximity. A safe app treats rendering, capability negotiation, user consent, and tool execution as four separate contracts.
This tutorial builds that separation around a travel-expense reviewer, including a keyboard-complete static fallback and a ledger for every call that leaves the UI sandbox.
MCP Apps need four visible trust boundaries
The expense example begins when a tool returns twelve line items and an optional interactive reviewer. The server owns the expense data and tool definitions, while the host owns the conversation, user identity, approval policy, and the surface that contains the app. The embedded document owns local presentation state such as sorting and selected rows. MCP Apps stay understandable when those owners are drawn separately instead of blending into one “agent UI” rectangle.
The dangerous shortcut is to treat the embedded interface as trusted because the host chose to render it. That can leak navigation, credentials, or consequential tool access across an origin boundary. Give the resource a narrow sandbox, pass only negotiated capabilities, and route calls back through host policy. The app may request an action, but the host remains the principal that authenticates, authorizes, displays review, invokes the tool, and returns a sanitized result.
Draw the boundary around messages as well as frames. An interactive MCP UI should reveal which values came from the server, which choices remain local, and which request crossed back through host authorization.
Pin the extension contract and its maturity
The official extension repository contains the specification and SDK work, the branch-qualified dated 2026-01-26 apps specification is the reviewable protocol text for this implementation, and the extension overview explains the model. Pin the document date or package version in the integration fixture because extension behavior can move faster than the surrounding product.
Do not turn examples into implied browser security guarantees. The host environment decides the actual iframe or webview sandbox, content-security policy, origin treatment, storage availability, and navigation rules. The protocol transports messages; it does not absolve the container from web security. Record both layers in the manifest so a reviewer can distinguish “allowed by the app protocol” from “enforced by this host runtime.”
Record the exact extension revision and one unsupported-host result beside the implementation. That pairing prevents a preview feature from becoming an undocumented requirement and gives support a stable explanation when rendering falls back. This makes fallback triage reproducible.
Negotiate capabilities instead of detecting accidents
Capability negotiation should happen before the interface exposes a control. The app declares what it can use, such as opening an approved link or asking the host to invoke one named read tool, and the host returns the supported subset. A disabled export button can then explain that this host does not offer file download, while the expense table still works. MCP Apps should never probe for incidental globals and treat a successful access as authorization.
Model the handshake as immutable session state with protocol version, app resource identity, host identity class, granted capabilities, and expiry. If the host revokes a capability, create a new state rather than mutating the old receipt. The UI derives affordances from that state, and every request names the grant it relies on. This makes unsupported hosts predictable and turns a security decision into something the accessibility layer can communicate clearly.
Capability negotiation also needs negative vocabulary: unavailable, denied, expired, and revoked are distinct states. Each should disable only the dependent action while leaving the expense data readable and the recovery step understandable.
Sandbox the resource as hostile presentation code
Assume the embedded resource can be compromised through its own dependency or through data rendered inside it. Start with no top navigation, popups, same-origin privilege, clipboard, camera, microphone, downloads, or ambient credential access. Add a capability only when the expense workflow names a user benefit and the host can mediate it. A strict content-security policy should also prevent unexpected scripts and network destinations, with nonces or hashes for the minimal code the app actually needs.
The common failure is a broad sandbox exception added to fix one broken demo. Its consequence lasts beyond that demo and may let a future resource reach host-adjacent state. Mitigate it with a permission diff in code review, a negative browser test for each forbidden surface, and a host allowlist keyed by resource identity. MCP Apps should fail by losing an enhancement, not by gaining an unreviewed ambient power.
Treat sandboxed agent interfaces as disposable renderers, not trusted workflow engines. Recreating the document from the signed resource and current tool result should never approve an expense or revive a grant by itself.
Keep tool calls host-mediated and auditable
When the reviewer marks two meals as personal, the app sends a typed request containing selected expense IDs and the intended command. The host validates that those IDs came from the current result, checks the user's role, presents the effect, and then invokes the server tool. The embedded document never receives a bearer token. It receives a minimal success or error object suitable for rendering, plus a stable operation ID for support.
The audit ledger records app resource digest, session grant, user subject, requested tool, validated arguments digest, consent surface version, invocation ID, server result digest, and final UI acknowledgement. This is more useful than a console transcript because it preserves the authority transition. MCP Apps become supportable when an operator can answer whether a call came from an allowed resource, used a current selection, passed policy, and produced the state now visible on screen.
The call ledger should join resource digest, user gesture, requested tool, normalized arguments, policy decision, and result ID. That is enough to audit authority without collecting private pointer movement or hidden reasoning.
| Capability | If granted | If absent | Owner |
|---|---|---|---|
| Tool read | Show live detail | Static summary | Host |
| Open link | Approved target | Copyable URL | Host |
| Local sort | Interactive table | Source order | App |
| Write effect | Review then call | Full-page flow | Host + user |
Design the non-interactive version first
The app's core meaning should survive when scripting, the extension, or a requested capability is unavailable. Return a semantic expense summary with a captioned table, totals, flags written in text, and ordinary links to the host's full review route. Do not hide the only explanation inside hover, color, drag order, or a canvas. This accessible fallback is also the fastest way to inspect whether the tool result carries enough meaning without presentation code.
Enhancement can add local filtering, row expansion, and a bounded selection model while preserving DOM order and names. Keyboard focus begins at a descriptive heading, moves through real buttons, and returns to the initiating control after a host-mediated dialog. Announce asynchronous results in a polite status region, but keep error details adjacent to the control. The same information architecture should work with screen reader, 200-percent zoom, high contrast, and reduced motion.
A plain table is more than an accessibility fallback; it is the incident interface. When scripts, extension support, or the host bridge fails, people still need to inspect amounts and continue through an ordinary route.
- 1Render
Host loads a sandboxed resource.
- 2Grant
Both sides record supported capabilities.
- 3Request
App proposes a typed action.
- 4Mediate
Host validates, confirms, invokes, and logs.
Treat UI state as disposable, business state as durable
Sorting by amount and expanding a receipt preview are local, disposable choices. Marking an expense personal is business state and must complete through the server. Selection sits between them: it may be local while the user composes a request, but the host validates every selected ID against the current tool result before action. This classification prevents an app reload from pretending that a consequential change was committed when only a checkbox changed.
Define lifecycle transitions for created, connected, suspended, disconnected, and destroyed. On suspension, stop timers and network-adjacent work; on reconnect, renegotiate capabilities instead of reusing stale grants. On destroy, erase ephemeral selection and message ports. MCP Apps need lifecycle discipline because an embedded surface can outlive the conversation state that authorized it, especially when clients keep transcripts, tabs, or cached resources open.
Persist only the business identifiers needed to reopen work. Sort order, selection paint, and expansion state may vanish with the resource, while approval status and comments remain on the server under ordinary access rules.
Test denial paths before polishing the happy path
Build a matrix covering an unsupported host, missing capability, expired grant, wrong resource digest, revoked user role, stale expense IDs, tool timeout, malformed result, lost connection, and app destruction during a request. Every case should leave business state unambiguous and keep focus somewhere sensible. The host must deny without disclosing whether an unauthorized record exists, while the UI gives the authorized user a practical recovery path.
Connect the result to MCP elicitation consent UX, MCP OAuth audience validation, human escape hatches, and interaction state foundations. Those controls cover consent, token audience, escalation, and complete UI states. The app protocol becomes one piece of a product safety system rather than a decorative iframe recipe.
Run the denial matrix with screen-reader output and reduced motion enabled. MCP Apps must name the blocked action and next step without depending on color, animation, hover, or knowledge of the embedding host. The receipt must preserve the denied capability.
Release the interface with a capability receipt
The release bundle should include the resource digest, protocol version, host support matrix, sandbox flags, content-security policy, capability declarations, fallback capture, keyboard transcript, screen-reader notes, negative-call tests, and audit-event schema. Capture one request from click through server receipt, then prove that the same request is denied after grant expiry. This creates operational evidence without pretending an automated check replaces manual assistive-technology review.
MCP Apps are most compelling when a rich interface remains less powerful than the host that contains it. Keep ambient authority at zero, negotiate every enhancement, route tool work through host policy, and preserve a useful semantic result when the interactive layer disappears. That architecture makes the UI feel native while keeping its origin, permissions, lifecycle, and calls inspectable.
Runnable artifact: The small gate permits only capabilities declared by the host and rejects both undeclared tool access and top-level navigation authority. Save it as mcp-app-capability-gate.test.mjs and run node mcp-app-capability-gate.test.mjs. Expected final line: PASS: UI capabilities gated.
import assert from "node:assert/strict";
const allow=(declared,request)=>request.every(cap=>declared.includes(cap))&&request.every(cap=>!cap.startsWith("top-navigation"));
assert.equal(allow(["tool:read","open-link"],["tool:read"]),true);
assert.equal(allow(["tool:read"],["tool:write"]),false);
assert.equal(allow(["top-navigation"],["top-navigation"]),false);
console.log("PASS: UI capabilities gated");
The release decision is equally concrete: an interactive result may ship when its resource runs with no ambient authority, its useful capabilities are explicitly granted, and every consequential request returns through host policy and user review. MCP Apps should make the expense workflow faster to understand while the static semantic result remains sufficient for unsupported clients, assistive technology, incident recovery, and audit. Richness is an enhancement layer, not a new trust tier.
Give the next reviewer the resource digest, sandbox flags, capability receipt, denied-call fixtures, fallback capture, and one end-to-end invocation ledger. Ask them to revoke the grant halfway through a session and recover with only keyboard input. If either test leaves the business state uncertain, the app is not ready even when its animation looks polished. Repeat the review when protocol text, host container, resource dependencies, or the expense tool contract changes.