Model Context Protocol · Web development · AI agents

Build an Interactive MCP App with a Sandboxed Tool UI

Design an MCP tool that serves an interactive UI resource, receives structured results, and stays safe across hosts with different extension support.

Published
Updated
Code examples
Illustrative
Reading time
6 minutes

An MCP App pairs a normal MCP tool with a ui:// resource containing an interactive web interface. A compatible host fetches that resource, renders it in a sandboxed iframe, sends tool results to it, and mediates any calls it makes back to MCP. Use this for charts, editors, maps, or review forms that are materially clearer than prose.

This guide targets the stable MCP Apps extension dated 2026-01-26. MCP Apps is an extension, not part of the core MCP specification, and host support varies. The exact SDK examples are intentionally omitted here until they are run end to end; the protocol design and raw payloads below are based on the stable extension specification and official documentation.

Start with an interface that earns its complexity

Imagine a capacity-planning tool. The model supplies a scenario ID, the tool returns structured monthly values, and the user adjusts assumptions in a chart before saving a proposal.

Without an app, the tool can return JSON or Markdown. With an app, the user can inspect the series, change bounded inputs, and invoke a separate save tool through the host. That separation is important:

  • load_capacity_scenario is read-only and produces data;
  • the UI visualizes and collects edits;
  • save_capacity_proposal validates and persists an explicit proposal; and
  • the host remains between the untrusted iframe and privileged MCP calls.

Do not build an app for a single confirmation button or a short scalar result. Every iframe adds loading, accessibility, compatibility, and security work.

Understand the four-party flow

model → host → MCP server tool
                │
                └── declares ui://capacity/planner.html

host → MCP server resource/read → HTML resource
host → sandboxed iframe          → renders UI
host → iframe                    → sends tool result
iframe → host → MCP server       → optionally calls another tool

The server does not directly inject its UI into the host document. The host owns the iframe, policy, permissions, and bridge. The view communicates with that bridge rather than assuming it can reach host internals.

The MCP Apps documentation describes three implementation roles: server authors register tools and UI resources, app developers build views, and host developers embed and communicate with views. Keep those roles separate even when one team owns all three.

Declare a tool-to-UI relationship

At discovery time, the read tool must point to the UI resource. The following is a protocol-level sketch; use the extension helper exposed by the SDK version you adopt instead of copying private metadata conventions from a vendor integration.

{
  "name": "load_capacity_scenario",
  "title": "Open capacity planner",
  "description": "Loads one planning scenario for interactive review.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "scenarioId": { "type": "string", "minLength": 1 }
    },
    "required": ["scenarioId"],
    "additionalProperties": false
  },
  "_meta": {
    "ui": { "resourceUri": "ui://capacity/planner.html" }
  }
}

The matching MCP resource uses the same URI and returns HTML with the extension's app media type:

{
  "contents": [
    {
      "uri": "ui://capacity/planner.html",
      "mimeType": "text/html;profile=mcp-app",
      "text": "<!doctype html><html>…bundled application…</html>"
    }
  ]
}

Use the stable specification for the authoritative metadata names and media type. Extension drafts may change, so do not implement against an unversioned blog snippet.

Return data for machines and people

The tool result should remain useful when the host cannot render Apps. Return structuredContent for the UI and compatible clients, plus concise text in content as a fallback.

{
  "content": [
    {
      "type": "text",
      "text": "Loaded Baseline FY27 with 12 monthly capacity values."
    }
  ],
  "structuredContent": {
    "scenario": {
      "id": "scenario_8f32",
      "name": "Baseline FY27",
      "currency": "USD",
      "months": [
        { "month": "2027-01", "demand": 120, "capacity": 135 }
      ]
    },
    "permissions": {
      "canSaveProposal": false
    }
  }
}

The one-month array is illustrative. In a real contract, declare and validate an output schema, bound array length, use an integer minor unit for money, and reject impossible dates. Never make the iframe parse numbers out of prose.

Also keep authorization-derived UI state advisory. A disabled Save button improves usability, but the server must authorize save_capacity_proposal independently on every call.

Build the view as an untrusted client

The official SDK exposes an App abstraction and postMessage transport for views. Regardless of framework, structure the view around four states:

type ViewState =
  | { kind: "loading" }
  | { kind: "ready"; scenario: CapacityScenario }
  | { kind: "saving"; scenario: CapacityScenario }
  | { kind: "error"; message: string };

On connection, the view should:

  1. complete the Apps bridge handshake;
  2. receive and validate the tool result;
  3. render a useful empty, error, or ready state;
  4. apply host-provided styles where supported; and
  5. expose keyboard-operable controls with visible labels.

If the user saves, send a narrow tool call through the host:

{
  "name": "save_capacity_proposal",
  "arguments": {
    "scenarioId": "scenario_8f32",
    "baseRevision": 17,
    "changes": [
      { "month": "2027-01", "capacity": 140 }
    ],
    "idempotencyKey": "proposal_31b9"
  }
}

baseRevision prevents silent overwrites. The server should return a conflict if the scenario changed after it was loaded. The idempotency key makes retrying a save safe after an ambiguous network failure.

Treat the iframe boundary as real security

A sandboxed iframe reduces access to the host page; it does not make the app trustworthy. The view receives data and may request tools, so the host and server still need controls.

For the server:

  • expose outcome-specific tools, not a generic HTTP request tool;
  • authorize every call using the authenticated principal and tenant;
  • validate output before it reaches the iframe and input when it returns;
  • separate read and write tools;
  • require approval for destructive or externally visible actions; and
  • avoid placing access tokens or secrets in tool results, resource HTML, URLs, or bridge messages.

For the host:

  • use the sandbox and content-security policy required by the stable extension;
  • validate message origin, source window, protocol shape, and lifecycle;
  • grant only capabilities declared by the extension and local policy;
  • show which server owns the UI and which tool an action will call; and
  • never allow the iframe to bypass the host and invoke arbitrary MCP operations.

For the view:

  • treat tool data as untrusted;
  • render text with text nodes rather than raw HTML;
  • do not depend on top-level navigation, cookies, or unrestricted network access;
  • support keyboard navigation, zoom, focus visibility, and reduced motion; and
  • surface errors without exposing stack traces or sensitive payloads.

The host fetches the UI resource from an MCP server, so resource integrity matters. Review and pin trusted servers; log the resource URI and server identity used for each rendering.

Handle hosts that do not support Apps

The app should degrade to its ordinary tool result. Test three paths:

Host capabilityExpected behavior
Full Apps supportInteractive view renders and receives structured data
Core MCP onlyText and structured result remain understandable
Apps bridge failsHost shows a safe error and preserves the tool result

Never make success depend solely on a UI notification that a non-supporting client will ignore. Host support varies by product and version, as the official MCP Apps client note emphasizes.

Release checklist

  • Pin the stable 2026-01-26 extension rather than the development draft.
  • Validate the exact tool metadata and app media type against the specification.
  • Bundle the UI so its resource loads under the host's sandbox and CSP.
  • Return accessible text and structured fallback output.
  • Verify schema validation at the tool and UI boundaries.
  • Test authorization, stale revisions, duplicate saves, and lost responses.
  • Test focus, keyboard operation, zoom, color contrast, and screen-reader labels.
  • Confirm that no secret appears in HTML, postMessage data, logs, or tool results.
  • Test at least one non-App client before publication.

If your capability does not need an interface, build a normal safe MCP tool first. The UI should clarify a real interaction, not conceal an oversized tool contract.