Skip to main content
All posts

How to Build an MCP App Host: Render Resources, Iframes, and Bridge Calls (September 2026)

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App TestingClaude ConnectorsClaude Connector TestingMCP HostHost Bridge
An MCP App host discovers tools, loads UI resources, renders sandboxed iframes, and mediates every bridge call.

An MCP App host discovers tools, loads UI resources, renders sandboxed iframes, and mediates every bridge call.

If you are building an MCP client, agent workspace, internal chat product, or test host, supporting MCP tools is only the first step. To show an MCP App, your host must discover the tool’s UI resource, render untrusted HTML in an isolated iframe, deliver tool data, and decide which actions the iframe may request.

TL;DR: An MCP App host needs a resource loader, isolated iframe, View bridge, capability model, cache policy, and tests. Keep the server MCP client separate from the iframe bridge. Wait for ui/notifications/initialized before sending tool data, mediate each View request, and send ui/resource-teardown before unmounting.

Why Host Support Takes More Than MCP Tools

Host implementers have to answer questions that app authors can leave to the runtime:

  • How do I render MCP Apps in a custom chat UI?
  • What does an MCP App host need to implement?
  • How does a host read ui:// resources?
  • How do iframe bridge messages map back to MCP tools?
  • What should the host cache?
  • How do I test MCP App support without depending on a live product?

Those questions matter because MCP Apps are only portable if hosts implement the same base contract. A server can expose perfect resources, but if the host does not read them, sandbox them, initialize them, or proxy bridge calls, the app cannot run.

This post is for host implementers. If you are building the app itself, start with What is an MCP App? or the MCP App tutorial. If you are building the surface that renders those apps, keep reading.

The Minimum Host Contract

An MCP App host has five jobs.

Host jobWhat it doesWhat breaks when it is missing
Tool discoveryCalls tools/list and finds UI-capable toolsThe host never knows an app exists
Resource loadingReads the ui:// resource from _meta.ui.resourceUriThe tool runs, but the iframe has no HTML
Iframe runtimeCreates a sandboxed frame and applies resource metadataThe app can leak privileges or fail under host constraints
Bridge routingMoves messages between iframe, host, model, and serverButtons and app state do nothing
Error handlingShows useful states for missing resources, denied actions, and failed callsUsers see blank boxes

The official MCP Apps overview describes the core pattern: tools point at app resources, hosts render those resources in sandboxed iframes, and the iframe talks to the host through a bridge. The MCP Apps AppBridge reference documents a host-side implementation of that bridge.

That means a host implementation starts in the MCP protocol, then crosses into browser runtime work.

Keep the Server and View Connections Separate

The host has two connections with different jobs. Its MCP client speaks to the server over the normal MCP transport. Its iframe bridge speaks to one View over postMessage. The MCP Apps architecture reference calls these the outer Client and the AppBridge. Do not forward the server’s initialization exchange into the iframe: the View has its own ui/initialize handshake, with host identity, context, and supported capabilities.

When connecting to a server, advertise io.modelcontextprotocol/ui in the client’s extension capabilities, including text/html;profile=mcp-app as a supported MIME type. A server can use that signal to offer UI-enabled tools while keeping useful text results for clients without UI. The capability detection guide covers that fallback.

For each View instance, wait for ui/notifications/initialized before sending ui/notifications/tool-input or ui/notifications/tool-result. Keep the invocation and iframe IDs together so a late tool result cannot land in another view. Before removing the iframe, send ui/resource-teardown and allow the View to finish its cleanup. These steps matter for reloads, navigation, cancellation, and multiple apps in one conversation.

Step 1: Discover UI-Capable Tools

Start with normal MCP setup. Connect to the server, negotiate protocol support with initialize, then call tools/list.

A UI-capable tool advertises a resource URI in metadata:

{
  "name": "show_invoice_review",
  "title": "Show invoice review",
  "description": "Open an interactive invoice review screen.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "invoiceId": { "type": "string" }
    },
    "required": ["invoiceId"]
  },
  "_meta": {
    "ui": {
      "resourceUri": "ui://invoice-review/main.html"
    }
  }
}

Do not infer UI support from the tool name, description, or result shape. The host should use the declared resourceUri.

At discovery time, store a small index:

type AppToolIndexEntry = {
  toolName: string;
  title?: string;
  resourceUri: string;
  inputSchema: unknown;
  annotations?: Record<string, unknown>;
};

function getAppTools(tools: Array<any>): AppToolIndexEntry[] {
  return tools.flatMap((tool) => {
    const resourceUri = tool._meta?.ui?.resourceUri;
    if (typeof resourceUri !== 'string' || !resourceUri.startsWith('ui://')) {
      return [];
    }

    return [
      {
        toolName: tool.name,
        title: tool.title,
        resourceUri,
        inputSchema: tool.inputSchema,
        annotations: tool.annotations,
      },
    ];
  });
}

This index lets your host preload resources, label UI affordances, and run conformance checks before a model calls the tool.

Step 2: Read the App Resource

After you find ui://invoice-review/main.html, call resources/read against the MCP server. The resource should return app HTML and metadata.

Validate at least these fields:

  • The returned resource URI matches the requested URI.
  • The MIME type is the MCP App HTML type your target hosts support.
  • The text content is non-empty HTML.
  • Resource metadata includes any CSP, permission, or presentation hints the app needs.

Keep this check strict. A stale resourceUri is one of the easiest ways to ship a tool that works as plain MCP but fails as an app.

async function readAppResource(mcp: McpClient, resourceUri: string) {
  const result = await mcp.readResource({ uri: resourceUri });
  const resource = result.contents?.[0];

  if (!resource || resource.uri !== resourceUri) {
    throw new Error(`Missing MCP App resource: ${resourceUri}`);
  }

  if (resource.mimeType !== 'text/html;profile=mcp-app') {
    throw new Error(`Expected MCP App HTML for ${resourceUri}`);
  }

  if (typeof resource.text !== 'string' || !resource.text.trim()) {
    throw new Error(`Empty MCP App resource: ${resourceUri}`);
  }

  return {
    html: resource.text,
    metadata: resource._meta ?? {},
  };
}

Some hosts preload app resources at connection time. Others fetch them lazily when the tool becomes relevant. Either can work, but your error path should be the same: if the resource cannot be read, the user should see a host-owned error state instead of a blank iframe.

Step 3: Create the Sandboxed Iframe

An MCP App is not a normal web page opened in a new tab. It is an embedded app that depends on host context and bridge messages. Your host controls the frame.

The iframe should be isolated from the host page:

  • no direct access to the parent DOM
  • no access to host cookies or local storage
  • no parent-window navigation by default
  • no network access outside allowed resource metadata
  • no browser permissions unless the host grants them

If your host renders the HTML through srcdoc, make sure the frame origin, CSP, and asset rules match your threat model. If you serve resources from a host-controlled origin, keep that origin separate from the main product origin. Do not render third-party app HTML directly in the same document as the chat UI.

The iframe boundary is not a small detail. It is the safety model that lets hosts render third-party UI inside conversations.

Step 4: Initialize the Bridge

The iframe needs runtime data. At a minimum, it needs to know:

  • which host is rendering it
  • the current display mode
  • theme, locale, safe area, viewport, and other host context
  • tool input, if available
  • tool result data, when the tool has run
  • which bridge capabilities the host supports

The bridge uses postMessage and JSON-RPC messages. Your host should validate every message before acting on it. Treat the iframe as untrusted, even if the MCP server belongs to your company, because a host may later render apps from more than one team.

A host-side bridge loop needs to handle protocol method names exactly. In particular, the current methods are ui/request-display-mode and ui/update-model-context, with hyphens. This sketch shows where host policy belongs if you implement the protocol directly:

window.addEventListener('message', async (event) => {
  if (event.source !== appIframe.contentWindow) return;

  const message = parseBridgeMessage(event.data);
  if (!message) return;

  switch (message.method) {
    case 'tools/call':
      await handleToolCall(message);
      break;
    case 'ui/request-display-mode':
      await handleDisplayModeRequest(message);
      break;
    case 'ui/update-model-context':
      await handleModelContextUpdate(message);
      break;
    default:
      respondWithUnsupportedMethod(message.id);
  }
});

This sketch omits JSON-RPC response handling and the initialization handshake. For a production host, use the official AppBridge with a separate MCP client, or implement the full protocol from the specification. AppBridge handles the handshake and can proxy server capabilities; the host still decides which actions to grant. Do not forward arbitrary iframe messages to the MCP server. The host decides which tools are callable, whether the user must approve a call, whether data may update model context, and how errors appear.

Step 5: Inject Tool Results

The model calls a tool through your host. The server returns normal MCP tool output. The host then has to split the result into two audiences:

  • The model needs content and, when useful, structuredContent.
  • The app iframe may need structuredContent plus UI-only _meta.

The app should not scrape the chat transcript. It should receive data through the host bridge.

For a simple tool run, the flow looks like this:

  1. The user asks a question.
  2. The model chooses a tool.
  3. The host calls tools/call on the MCP server.
  4. The server returns content, structuredContent, and optional _meta.
  5. The host sends the relevant tool result payload to the iframe.
  6. The iframe renders deterministic UI.

This is the moment where many host prototypes break. They can call MCP tools, but they do not keep a stable mapping between a tool invocation, its app resource, and the iframe instance that should receive the result.

Track that mapping explicitly:

type AppInvocation = {
  invocationId: string;
  toolName: string;
  resourceUri: string;
  iframeId: string;
  status: 'loading' | 'ready' | 'error';
};

That record gives you a place to handle retries, reloads, streamed updates, cancelled calls, and multiple app instances in one conversation.

Step 6: Decide Which App Actions You Support

Once a resource can render, app authors will expect actions.

Start with the base actions that make apps usable:

ActionHost responsibility
tools/callProxy app-initiated tool calls to the MCP server after policy checks
app stateStore or relay model-visible UI state
display modeLet the app request inline, fullscreen, or picture-in-picture when supported
host contextSend theme, locale, viewport, safe area, and capability data
model contextLet the app share selected state back with the model when allowed

Then add optional actions based on your product:

  • opening external links
  • downloading files
  • uploading files
  • sending a message into the conversation
  • requesting browser permissions
  • using host-owned modals

Every optional action needs feature detection. The app should ask what the host supports, and the host should return a clear denial when it does not support an action. Silent failure is hard to debug because the app runs inside a sandbox and cannot inspect the host directly.

Step 7: Handle Cache and Dev Mode

Resource caching is where host implementations start to feel real.

Production hosts should avoid refetching large resource bundles on every tool call. Developer hosts should avoid showing stale bundles after every code change. Those goals pull in opposite directions, so make the policy explicit. OpenAI’s ChatGPT UI guide treats the resource URI as a cache key and recommends a new URI for incompatible HTML, JavaScript, or CSS changes.

For production:

  • cache app resources by server identity and resourceUri
  • use a new URI when the app bundle changes incompatibly
  • refetch when resource metadata or tool references change
  • keep a manual refresh path for support and debugging

For development:

  • disable long-lived resource caching
  • show the loaded resource URI and timestamp
  • provide a reload button
  • log resource read failures visibly
  • make stale bundle errors easy to spot

This is one reason sunpeak’s Inspector is useful even when you are not using sunpeak as the app framework. It gives developers a local host runtime with refresh behavior built for MCP App iteration.

Step 8: Build Host-Side Error States

A blank iframe is not an error state. It is a missing product decision.

Your host should have clear UI for:

  • no UI resource declared
  • resource URI is invalid
  • resources/read failed
  • resource MIME type is wrong
  • iframe failed to initialize
  • bridge method is unsupported
  • tool call failed
  • user denied a risky action
  • CSP blocked an asset or API call
  • the app requested a display mode the host cannot provide

The user does not need protocol details, but the developer does. In developer mode, expose enough diagnostics to answer:

  • Which tool was called?
  • Which resource URI was read?
  • Which MIME type came back?
  • Which bridge method failed?
  • Which CSP directive blocked the request?
  • Which MCP error code came back from the server?

That is the difference between a host developers can use and a host developers can trust.

Step 9: Test the Host Like a Browser Runtime

You need two test layers.

Protocol tests check the MCP side:

  • initialize negotiates the expected protocol version and capabilities.
  • tools/list finds UI-capable tools.
  • every _meta.ui.resourceUri can be read with resources/read.
  • resource HTML and metadata pass validation.
  • tools/call returns useful fallback content.

Browser tests check the host runtime:

  • the iframe mounts and initializes
  • fixture tool results render in the app
  • bridge calls reach the right handler
  • denied actions return useful errors
  • display modes resize without layout breakage
  • theme, safe area, locale, and viewport changes propagate
  • multiple app instances do not share state by accident
  • the host sends ui/resource-teardown before it removes the iframe
  • CSP and CORS failures appear in diagnostics

Use one tiny fixture app as your host conformance app. It should render tool data, call a read-only server tool, update app state, request a display mode change, attempt a blocked link, and show received host context. That one fixture can catch most host regressions before you try a real third-party app.

Where sunpeak Fits

sunpeak is primarily an MCP App framework and testing framework for app authors, but its Inspector is also a reference point for host behavior. It runs MCP App resources in local ChatGPT and Claude-style runtimes, so you can inspect tool metadata, render resources, switch hosts, change display modes, vary themes, and run automated browser tests without paying for live host accounts in the normal loop.

If you are building an MCP App host, use sunpeak to test the other side of the contract:

npx sunpeak inspect --server https://your-server.example.com/mcp

Then compare your host against the same server and fixture states. If the app works in the inspector but not in your host, the gap is usually in resource loading, sandbox policy, bridge routing, cache invalidation, or action support.

Practical Build Order

Do not try to implement every host feature at once.

Build in this order:

  1. MCP connection, initialize, and tools/list.
  2. UI tool detection through _meta.ui.resourceUri.
  3. resources/read validation for every app resource.
  4. sandboxed iframe rendering for one known fixture app.
  5. a separate View bridge with ui/initialize, host context, capabilities, and the initialized signal.
  6. tool input and result delivery after the View is ready.
  7. app-initiated tools/call with policy checks.
  8. display modes, app state, and model context updates.
  9. optional links, downloads, files, modals, and permissions.
  10. graceful teardown, cache policy, developer refresh, diagnostics, and conformance tests.

That order keeps the host honest. A fancy app shell does not matter if the host cannot read the resource. A bridge API does not matter if the host forwards messages without policy. Cache does not matter if stale bundles make development impossible.

The base contract is small enough to implement, but it crosses protocol, browser, product, and security boundaries. Treat your MCP App host like a browser runtime for AI apps, because that is the job users will expect it to do.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is an MCP App host?

An MCP App host is an AI client, chat product, agent workspace, or developer tool that connects to MCP servers and renders MCP App resources. The host discovers tools, reads ui:// resources, creates sandboxed iframes, passes tool results into the app, and mediates bridge calls such as server tool calls, display mode requests, and model context updates.

How does a host know an MCP tool has an app UI?

The host calls tools/list and checks each tool for _meta.ui.resourceUri. That URI points to a ui:// resource. The host then calls resources/read for that URI, validates the returned MCP App HTML and resource metadata, and renders the resource when the tool runs.

Can an MCP App host just open the resource URL in a browser tab?

No. An MCP App resource depends on host-injected context, tool input, tool results, display mode state, and bridge APIs. A useful host must render the resource in a controlled iframe, initialize the bridge, pass runtime data into the app, and handle requests that come back from the iframe.

What should an MCP App host cache?

Cache UI resources by server identity and resource URI, and keep an explicit refresh path. Treat the URI as a cache key: when an app author ships incompatible HTML, JavaScript, or CSS, the tool should point to a new URI. In development, let people bypass the cache and show which URI and load time the host used.

What bridge calls should an MCP App host support first?

Start with the ui/initialize handshake, the ui/notifications/initialized ready signal, tool input and result notifications, host context, and tools/call. Add ui/update-model-context, ui/request-display-mode, links, downloads, and messages only when your host can enforce their permissions and return clear results. Send ui/resource-teardown before removing a view.

How should a host handle unsafe MCP App actions?

Treat the iframe as untrusted UI. The host should validate bridge messages, enforce resource CSP and permissions, gate dangerous tool calls through user consent where needed, respect tool annotations, and keep the iframe from touching host DOM, host cookies, or parent-window navigation.

How do I test an MCP App host implementation?

Write protocol tests for initialize, tools/list, resources/read, and tools/call. Add browser tests that render a known MCP App resource in an iframe, verify initialization, pass fixture tool results, test bridge calls, vary display modes and themes, and assert that blocked CSP, missing resources, and failed server tools show useful errors.