---
title: "MCP Apps"
description: "Author and embed interactive MCP App UIs inside Claude, ChatGPT, and other compatible hosts — using real app routes, the embed bridge, and the host bridge API."
---

# MCP Apps

**This page: inline UIs in Claude/ChatGPT.** Authoring MCP App resources and the embed bridge that renders a real app route inside a compatible host's chat. This page is also the single home for the **host support matrix** ([below](#client-support)).

| If you want to…                                              | Read                                     |
| ------------------------------------------------------------ | ---------------------------------------- |
| Connect an external agent/host to your app                   | [External Agents](/docs/external-agents) |
| Give your agent more tools (consume other MCP servers)       | [MCP Clients](/docs/mcp-clients)         |
| Build inline UIs that render in Claude/ChatGPT               | **This page** — MCP Apps                 |
| Build generated inline UI inside Agent-Native chat           | [Generative UI](/docs/generative-ui)     |
| Lower-level MCP server reference (auth, tools, custom mount) | [MCP Server](/docs/mcp-protocol)         |

MCP Apps are the official `io.modelcontextprotocol/ui` extension that lets compatible hosts — Claude, Claude Desktop, ChatGPT, VS Code GitHub Copilot, Goose, Postman, MCPJam, and Cursor — render interactive UIs inline in chat. In agent-native apps, every MCP App is a **real React route**, not a separate plain-HTML widget.

Inside an Agent-Native app's own chat, prefer [native chat renderers](/docs/native-chat-ui) for first-party widgets such as tables, charts, typed results, and approval affordances. Use [Generative UI](/docs/generative-ui) when the in-app agent should create arbitrary sandboxed controls or visualizers inline. Use MCP Apps for external/cross-host inline UI in Claude, ChatGPT, Copilot, Cursor, and other compatible hosts, with the action `link` as the universal deep-link fallback.

## Authoring: optional MCP Apps UI {#mcp-apps}

For hosts that support the MCP Apps extension, an action can also advertise an inline UI resource with `mcpApp`. This is a progressive enhancement for flows where the external agent should hand the user an interactive surface instead of only text — for example reviewing an email draft, editing a calendar invite, or choosing between generated dashboard variants.

Use the real React app with `embedRoute()` or `embedApp()` whenever the user needs UI. The mental model is simple: the action's `link` target is also the MCP App embed target. Expose the operation as a normal action/tool, return a focused deep link with `link`, and add `mcpApp.resource = embedApp(...)` so capable hosts load that same route inline instead of opening a new tab. When both should be built from the same route, prefer `embedRoute({ title, openLabel, path })`: it is the convenience wrapper that returns matching `link` and `mcpApp` fields from one call, while `embedApp(...)` is the lower-level resource you assign to `mcpApp.resource` directly.

That means full-app embeds can do anything the route can do once opened: review or edit an email draft, show a filtered inbox/search, open a calendar event or event draft, load an extension page, inspect a full analytics dashboard or saved analysis, continue a deck in the Slides editor, or open a Design project/editor. Prefer URL/deep-link params and the existing `/_agent-native/open` navigation/app-state bridge over inventing a second state protocol for MCP Apps.

On rare occasions the right target is a focused app route that renders one shared React component instead of the whole app shell. Analytics' `/chart` route is the model: it takes a compact `SqlPanel` payload in the URL and renders the same chart component the dashboard uses. This is still an app embed, not a plain HTML MCP App. Expose or call it through a normal action / `open_app({ path, embed: true })`, keep the URL deterministic, and let `embedApp()` render that route inline.

Do not hand-write one-off plain HTML MCP Apps for product UI; if the action needs a custom surface, add or reuse a real app route/component first and embed that route.

<Diagram id="doc-block-18hp92f" title="MCP App embed round-trip" summary={"The action's link target is also the embed target. Capable hosts load the same signed app route inline; everyone else falls back to the deep link."}>

```html
<div class="diagram-embed">
  <div class="diagram-card" data-rough>
    <strong>Action</strong
    ><small class="diagram-muted">`link` target = MCP App embed target</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-card" data-rough>
    <strong>embedApp()</strong
    ><span class="diagram-pill accent">create_embed_session</span
    ><small class="diagram-muted">mints short-lived embed session</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-card" data-rough>
    <strong>/_agent-native/embed/start</strong
    ><small class="diagram-muted">exchanges one-time SQL ticket</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-card" data-rough>
    <strong>Signed app route</strong
    ><span class="diagram-pill ok">real React route</span
    ><small class="diagram-muted">short-lived browser session</small>
  </div>
  <div class="diagram-fallback">
    <span class="diagram-pill warn">no MCP Apps support</span>
    <div class="diagram-arrow diagram-muted" aria-hidden="true">&darr;</div>
    <div class="diagram-box" data-rough>
      &quot;Open in … &rarr;&quot; deep link
    </div>
  </div>
</div>
```

```css
.diagram-embed {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}
.diagram-embed .diagram-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 14px 16px;
  min-width: 140px;
}
.diagram-embed .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
.diagram-embed .diagram-fallback {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  margin-inline-start: 8px;
}
```

</Diagram>

<AnnotatedCode
  id="doc-block-exq36i"
  title="The mcpApp resource config"
  filename="actions/review-draft.ts"
  language="ts"
  code={
    'import { embedApp } from "@agent-native/core";\n\nexport default defineAction({\n  // ...description, schema, run, link...\n  mcpApp: {\n    resource: embedApp({\n      title: "Review draft",\n      description: "Open the generated draft in the real Mail compose UI.",\n      iframeTitle: "Agent-Native Mail",\n      openLabel: "Open in Mail",\n    }),\n  },\n});'
  }
  annotations={[
    {
      lines: "6",
      label: "Progressive enhancement",
      note: "`mcpApp.resource` advertises an inline UI for hosts that support the MCP Apps extension. Keep the action's `link` builder too — CLI-only and older hosts ignore the UI metadata and still need the deep link.",
    },
    {
      lines: "7",
      label: "Embed = the link target",
      note: "`embedApp()` uses the action's `link` as its launch target: it calls `create_embed_session`, exchanges a one-time SQL ticket at `/_agent-native/embed/start`, and navigates the MCP App frame to the same signed app route.",
    },
    {
      lines: "11",
      label: "Universal fallback label",
      note: '`openLabel` is the visible `"Open in … →"` text used as the deep-link escape hatch when a host does not render the inline iframe.',
    },
  ]}
/>

The MCP server advertises extension `io.modelcontextprotocol/ui`, adds `_meta.ui.resourceUri` plus `_meta["ui/resourceUri"]` to `tools/list`, and also emits ChatGPT Apps SDK compatibility metadata (`openai/outputTemplate`, widget CSP/description/accessibility). It exposes the HTML through `resources/list`, `resources/templates/list`, and `resources/read` using MIME `text/html;profile=mcp-app`. The stdio proxy forwards those resource handlers from the live app, so desktop and CLI clients see the same resources as HTTP clients.

Keep the existing `link` builder even when adding `mcpApp`. CLI-only clients, older hosts, and any host that does not render MCP Apps will ignore the UI metadata and still need the `"Open in … →"` link. `embedApp()` uses that link as its launch target, calls the app-only `create_embed_session` helper, exchanges a one-time SQL ticket at `/_agent-native/embed/start`, and navigates the MCP App frame to the target route with a short-lived browser session plus a bearer fallback for same-origin fetches. `open_app({ app, path, embed: true })` is the generic escape hatch for routes such as full dashboards, filtered inboxes, calendar draft views, analyses, and extension pages, and should be used liberally when the full app is the clearest review/edit surface.

`embedApp()` includes the MCP request origin in the resource CSP so the launcher can fetch and, when explicitly requested, frame the signed first-party app route. Dispatch adds the exact origins for the granted apps to its `open_app` resource so a single Dispatch connector can inline Mail, Calendar, Slides, and the rest without allowing every HTTPS origin. Only pass additional frame or resource domains for a custom MCP App that truly embeds a third-party player or loads third-party assets.

Inside those `embedApp()` routes, `sendToAgentChat()` is embed-aware. Auto-submitted prompts relay to the MCP host as `ui/update-model-context` plus `ui/message`, so a button in the embedded app can intentionally continue the Claude/ChatGPT conversation from the selected app state. Hidden context is sent as model context; the visible user turn stays just the app's prompt, which avoids scary host consent around internal app-state file paths. `submit: false` remains local prefill/review behavior.

## First-class MCP App bridge {#mcp-app-bridge}

MCP App embeds are route embeds, not separate mini-products. `embedApp()` starts from the action's `link` target, creates a short-lived embed session, and launches that signed app route. Standard MCP Apps hosts can navigate the MCP App frame itself when the host can hydrate the route directly.

<Diagram id="doc-block-k0bblu" title="Two host bridge paths, one signed route" summary={"Claude transplants the hydrated route and uses the direct ui/_bridge; ChatGPT gets a controlled iframe via window.openai and relays host actions over postMessage. Both point at the same signed app route."}>

```html
<div class="diagram-bridge">
  <div class="diagram-col">
    <div class="diagram-card" data-rough>
      <strong>Claude web</strong
      ><span class="diagram-pill accent">single-frame transplant</span
      ><small class="diagram-muted"
        >hydrates signed app HTML in Claude's iframe, then direct `ui/*` host
        bridge</small
      >
    </div>
    <div class="diagram-card" data-rough>
      <strong>ChatGPT web</strong
      ><span class="diagram-pill accent">controlled route iframe</span
      ><small class="diagram-muted"
        >`window.openai` host APIs · `agentNative.mcpHost.*` postMessage
        relay</small
      >
    </div>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-box" data-rough>
    Same signed app route<br /><small class="diagram-muted"
      >normal route + React components</small
    >
  </div>
</div>
```

```css
.diagram-bridge {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}
.diagram-bridge .diagram-col {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.diagram-bridge .diagram-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 14px 16px;
  max-width: 300px;
}
.diagram-bridge .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
.diagram-bridge .diagram-box {
  padding: 16px 18px;
  text-align: center;
}
```

</Diagram>

Claude web uses a single-frame transplant path: the resource document fetches the signed app HTML and hydrates it inside Claude's MCP App iframe because Claude does not reliably allow app-owned child iframes or external frame navigation. ChatGPT web gets a controlled route iframe because its Apps bridge gives us stable `window.openai` host APIs and bounded height control. All paths point at the same signed app route and render the normal route and React components. Design embedded routes so a reload with the same signed URL reconstructs the same view.

A few bridge mechanics worth knowing:

- **Embed-start tickets are reused, not re-minted, when possible.** For same-app `open_app({ embed: true })`, the framework mints the embed-start ticket during the original tool call and stores the signed start URL in hidden tool metadata. Custom actions can return `embedStartUrl` for the same fast path; the MCP layer strips that ticket-bearing URL from model-visible `structuredContent` and normal open-link metadata. When no embed start URL is present, the resource falls back to the app-only `create_embed_session` helper — this keeps production hosts that restrict iframe-initiated tool calls on the direct route without leaking one-time app session URLs into the transcript. If a user reopens an old chat after a one-time start ticket has expired, the start route returns a small refresh page and posts `agentNative.embedSessionExpired` to the wrapper; `embedApp()` clears the stale start URL and mints a fresh ticket through `create_embed_session` when it still has the original app route.
- **ChatGPT's compatibility path** reads `toolInput`, `toolOutput`, and `toolResponseMetadata` directly through `window.openai`, then calls `create_embed_session` via `window.openai.callTool(...)`. Standard MCP Apps hosts use the `ui/*` JSON-RPC bridge instead — directly hydrated routes call `ui/update-model-context`, `ui/message`, `ui/open-link`, and `ui/request-display-mode` through the host bridge helpers, and Claude's transplanted route uses that same direct `ui/*` bridge after hydration. When the ChatGPT or explicit diagnostic iframe path is used, the wrapper relays the same host actions over `agentNative.mcpHost.*` postMessage requests instead. Keep the result shape identical for both paths: return a focused `link` and concise structured content.
- **Don't set standard `_meta.ui.domain` to an app URL.** MCP Apps treats that field as host-specific: Claude validates `{hash}.claudemcpcontent.com`-style sandbox domains, while ChatGPT uses its own `openai/widgetDomain` metadata. Omit `ui.domain` unless you are deliberately emitting a host-specific value; the host will choose a default sandbox origin.
- **Extension pages keep their sandbox** in MCP chat embeds without navigating a second route iframe. Normal app usage renders `/_agent-native/extensions/:id/render` as a sandboxed child iframe; in MCP chat bridge mode the framework renders the same extension document as sandboxed `srcDoc` inside the route iframe, avoiding host `frame-ancestors` / `X-Frame-Options` failures while preserving `sandbox="allow-scripts allow-forms"`.
- **The resource shell owns the outer host size.** `embedApp({ height })` defaults to `560px`, clamps the shell to `320-900px`, and reserves `44px` for the small toolbar, so the route viewport is `height - 44px`. Keep embedded app routes internally scrollable and let the launcher report that bounded intrinsic height rather than the full document height — otherwise host auto-resize can turn a normal app page into a very tall chat artifact. A changed shell only affects new MCP App resources and new tool calls; old ChatGPT/Claude conversation frames can keep the previous resource behavior, so verify sizing with a fresh inline render before judging a fix.

### Embed modes {#embed-modes}

Claude uses the single-frame transplant path by default. You can also force it in other hosts with `embedMode: "transplant"` when debugging host module-loading behavior, or force the nested diagnostic iframe with `embedMode: "iframe"`. `embedMode` is the canonical option name; `renderMode`, `frame`, and `nested: true` (for the iframe case) are recognized aliases kept for compatibility — prefer `embedMode` in new code. If the iframe is blocked, `embedApp()` replaces it with an open-app fallback: the user can retry inline, open a freshly minted embed session through the host, or use the visible route URL. Keep the action's `link` target useful on its own because it is still the universal escape hatch.

When testing Claude through ngrok, use a production build (`npx @agent-native/core@latest build` then `npx @agent-native/core@latest start`) or a deployed preview/production URL. Claude's single-frame transplant path works with production asset chunks; raw Vite dev modules such as `/app/root.tsx` can be protected by app auth and fail dynamic imports from the Claude resource origin.

## Host bridge API {#host-bridge}

The host bridge is deliberately small:

| Mode                   | Message type                          | Use it for                               |
| ---------------------- | ------------------------------------- | ---------------------------------------- |
| direct host route      | `ui/update-model-context`             | Hidden context for the host model        |
| direct host route      | `ui/message`                          | Post a visible user turn into the host   |
| direct host route      | `ui/open-link`                        | Open an external or app URL via the host |
| direct host route      | `ui/request-display-mode`             | Request `inline`, `fullscreen`, or `pip` |
| Claude transplant      | `ui/*`                                | Same direct host bridge after hydration  |
| ChatGPT / iframe route | `agentNative.mcpHostContext`          | Theme, locale, host platform, dimensions |
| ChatGPT / iframe route | `agentNative.embeddedAppReady`        | Confirm the route iframe loaded          |
| ChatGPT / iframe route | `agentNative.mcpHost.*` / `.response` | Wrapper relay for host requests          |

Embedded routes can use `updateMcpAppModelContext()`, `openMcpAppHostLink()`, `requestMcpAppDisplayMode()`, `getMcpAppHostContext()`, and `useMcpAppHostContext()` from `@agent-native/core/client`. `sendToAgentChat()` uses the same path from full-app embeds for auto-submitted prompts.

Display mode is best-effort. The in-app `McpAppRenderer` currently reports an inline web host context and an inline-only display mode; external hosts may honor larger display requests, ignore them, or reply with an unsupported-mode error. Always keep the inline route usable.

## Host support and caching {#client-support}

The current official MCP Apps client list includes Claude, Claude Desktop, VS Code GitHub Copilot, Goose, Postman, MCPJam, ChatGPT, and Cursor; host support still varies by plan, release channel, and client version, so check the [MCP extension support matrix](https://modelcontextprotocol.io/extensions/client-matrix). ChatGPT custom MCP apps are available through developer mode for Business and Enterprise/Edu workspaces on ChatGPT web; see OpenAI's [developer mode and MCP apps](https://help.openai.com/en/articles/12584461-developer-mode-and-full-mcp-apps-in-chatgpt-beta) notes.

Claude Code, Codex, and other CLI/code-editor clients still receive the same resources and metadata when they support MCP Apps, but treat them as link-out hosts unless you have verified inline iframe rendering in that exact surface. The deep link remains the reliable fallback when a host chooses not to render an iframe. In practice, every agent-native app should be authored with both: MCP Apps for inline review/edit in capable hosts, and `link` for universal round-tripping back to the full app.

Claude and ChatGPT can cache tool and resource metadata for an existing custom connector. After changing MCP App metadata, verify with a fresh tool call; if the host still uses the old descriptor, reconnect the Claude connector or rescan/review the ChatGPT connector so it refreshes the catalog. If Claude logs a warning about `_meta.ui.csp` or `_meta.ui.permissions` living on the tool descriptor after a deploy, that connector is using stale metadata: delete/reconnect the Claude connector and start a fresh chat.

## Testing {#testing}

Test MCP Apps with the lightweight fixtures around `embedApp()` and `McpAppRenderer`; they cover CSP, host context, app launch, and bridge message behavior without needing a real external host. When validating ChatGPT or Claude web, trigger a fresh tool call after shell changes and measure the visible iframe. Previously rendered frames in the same conversation may still show cached height or launch behavior.

## What's next {#whats-next}

- [External Agents](/docs/external-agents) — connecting Claude, ChatGPT, Codex, and Cursor to hosted apps; deep links.
- [External Agents Catalog](/docs/external-agents-catalog) — catalog tiers, the MCP Apps compatibility matrix, and the `link` builder for template authors.
- [MCP Server](/docs/mcp-protocol) — the auto-mounted MCP server, auth, tools, and `ask-agent`.
- [Actions](/docs/actions) — `defineAction`, the `link` builder, `publicAgent`.
- [Generative UI](/docs/generative-ui) — generated inline UI inside the app's own chat.
