All posts

How to Build an MCP App Host: Render Resources, Iframes, and Bridge Calls

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.

Most MCP App guides are written for app authors. They explain how to define a tool, return structuredContent, link a ui:// resource, and build a React component that runs inside ChatGPT, Claude, or another host.

There is a second search intent behind the same keywords: “How do I render MCP Apps in my own AI product?”

If you are building an MCP client, agent workspace, internal chat product, or test host, you are on the other side of the contract. Your job is to discover UI-capable tools, fetch the app resource, render it safely, pass data into it, and decide which actions the iframe can request.

TL;DR: An MCP App host needs more than a tool caller. It needs a resource loader, iframe runtime, bridge implementation, capability model, cache policy, and test suite. Start with initialize, tools/list, _meta.ui.resourceUri, resources/read, iframe sandboxing, and tools/call. Add display modes, app state, model context updates, links, downloads, and files only after you can validate and test them.

Why This Is the Search Gap

The largest uncovered MCP App intent is not another “build your first app” tutorial. Developers are starting to ask host-side questions:

  • 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. OpenAI’s MCP Apps in ChatGPT guide describes the same portable app model for ChatGPT Apps.

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

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 is usually implemented with postMessage and JSON-RPC-style 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 enterprise hosts will eventually render apps from many teams and vendors.

A host-side bridge loop usually has this shape:

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/requestDisplayMode':
      await handleDisplayModeRequest(message);
      break;
    case 'ui/updateModelContext':
      await handleModelContextUpdate(message);
      break;
    default:
      respondWithUnsupportedMethod(message.id);
  }
});

Do not forward arbitrary iframe messages to the MCP server. The host is the policy layer. It decides which tools are callable from the app, whether the user needs to approve the call, whether the result can 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.

For production:

  • cache app resources by server identity and resourceUri
  • include server version or resource revision when available
  • refetch on reconnect when tools or resources changed
  • respect resource metadata changes
  • 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
  • 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. bridge initialization with host context and capabilities.
  6. tool result injection after tools/call.
  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. 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 resource HTML and static assets by resource URI and server version when you can, but keep cache invalidation explicit. A host should refetch resources when the MCP server reconnects, when tools or resources change, when resource metadata changes, or when a developer mode session asks for a fresh bundle.

What bridge calls should an MCP App host support first?

Start with initialization, tool data delivery, host context, display mode state, app state, and tools/call. Then add optional actions such as open link, download file, send message, update model context, and display mode requests based on the user experience your host supports.

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.