teachyou.ai academy
← All posts
MCPmcp-uiapps sdkchatgpt appsagent ux

MCP Apps: Interactive UI Inside AI Chat Clients

Pramod Dutta · Jul 7, 2026 · 20 min read

MCP apps are interactive user interfaces that a Model Context Protocol server can render directly inside an AI chat client: dashboards, forms, seat pickers, kanban boards, all living inline in the conversation instead of being described in prose. Instead of a tool returning plain text for the model to narrate, the tool returns (or points at) an HTML template that the host renders in a sandboxed iframe and wires back to the model over JSON-RPC. The pattern was standardized in November 2025 as MCP Apps (SEP-1865), an official extension to the Model Context Protocol co-authored by engineers from Anthropic and OpenAI together with the maintainers of the community project MCP-UI, and it is the same machinery that powers apps inside ChatGPT.

If you have ever watched a model spend four paragraphs describing three flight options that a human would compare in a two-second glance at a card layout, you already understand why this exists. Text is a great interface for reasoning and a terrible one for choosing between structured options, monitoring live state, or entering data with constraints. MCP apps fix the last mile: the model still does the reasoning, but the answer can arrive as something you click.

This article covers what MCP apps actually are, how the extension works at the protocol level, how the three overlapping ecosystems (MCP-UI, the OpenAI Apps SDK, and the official MCP Apps extension) fit together, and then builds a working interactive server in TypeScript that you can test locally before shipping it anywhere.

What MCP Apps Are (and What They Are Not)

Quick recap for anyone arriving fresh: the Model Context Protocol is the open standard, released by Anthropic in late 2024 and since adopted across the industry, that lets an AI application (the host) connect to external servers exposing tools, resources, and prompts. A weather server exposes a get_forecast tool, the model calls it, the result comes back as text or structured JSON, and the model works it into the conversation.

MCP apps add one thing to that picture: a tool can now ship a user interface along with its result. The interface is ordinary web content (HTML, CSS, JavaScript) that the host client renders inside the chat transcript, sandboxed in an iframe. The widget can receive the tool output, respond to clicks, call other tools on the same server, send follow-up prompts to the model, and persist state across conversation turns. From the server author's point of view, you are still writing an MCP server; you are just attaching a face to it.

It helps to be precise about what MCP apps are not, because the terminology in this space got crowded during 2025:

  • They are not artifacts. Artifacts (in Claude) and canvas-style surfaces in other clients are model-generated content: the model writes the code and the client previews it. An MCP app is server-authored UI, versioned and shipped by you, bound to specific tools.
  • They are not embedded browser tabs. The point is not to iframe your entire SaaS into a chat window. Widgets are small, task-scoped surfaces that share context with the model.
  • They are not a separate protocol. MCP Apps is an optional extension negotiated on top of a normal MCP session. Hosts that do not support it still see your regular tool results, which is why every UI-bearing tool should also return a meaningful text fallback.

How MCP Apps Work: Templates, Iframes, and JSON-RPC

The extension rests on three design decisions, and once you internalize them the rest of the spec reads naturally.

First, UI templates are MCP resources with a dedicated URI scheme. A server declares its interface as a resource under ui://, for example ui://deploy-panel/v1, served with an HTML mime type. Because templates are predeclared resources rather than arbitrary strings injected at response time, hosts can fetch them ahead of time, review them, and cache them. The template itself contains no per-request data; data arrives separately through tool results.

Second, rendering happens in a sandboxed iframe. The host creates an iframe with a restrictive sandbox, loads your HTML into it, and keeps it isolated from the host page: no access to the host DOM, no host cookies, no shared origin. Your script runs, but it runs in a box.

Third, all communication between the widget and the host is JSON-RPC over postMessage. This is the clever part. Rather than inventing a new message format for iframe-to-host chatter, the extension reuses MCP's own wire format. When your widget wants to call a tool, it sends what is structurally an MCP request through postMessage, and the host mediates it exactly like any other tool call, including permission prompts and logging. Every message crossing the boundary is auditable.

Put together, the lifecycle of an MCP app looks like this:

  1. During initialization, host and server negotiate support for the UI extension via capabilities.
  2. The server declares one or more UI templates as ui:// resources, and each interactive tool references its template through tool metadata.
  3. The user asks for something, and the model decides to call the tool.
  4. The host sees the template reference, fetches the template (often from cache), and renders it in a sandboxed iframe inline in the chat.
  5. The tool result data is delivered into the iframe, and the widget hydrates itself.
  6. The user interacts. Clicks become JSON-RPC messages over postMessage: tool calls, prompt submissions, intents, notifications.
  7. The host executes mediated actions through its normal pipeline and streams results back into the widget, while widget state can persist across turns.

The initial version of the extension deliberately supports only inline HTML content. External URLs and remote-DOM style rendering (where the widget ships a script that drives host-native components) existed in MCP-UI before standardization and are on the roadmap as follow-ups, but the standardized core is: HTML template in, sandboxed iframe out, JSON-RPC across the boundary.

MCP-UI, the Apps SDK, and the Official Extension

Three names keep coming up around MCP apps, and they are related but not identical. Knowing which is which saves you hours of confused documentation reading.

MCP-UI is the community project that started it all in early 2025, created by Ido Salomon and Liad Yosef. It defined the ui:// scheme, the UIResource payload, and the action message vocabulary, and shipped SDKs for servers (TypeScript, with Ruby and Python helpers) and clients (React and web components). Block's agent Goose was among the first hosts to render MCP-UI resources, and tools like Postman and the MCPJam inspector followed. In MCP-UI's original model, the UI resource is embedded directly in the tool result rather than predeclared.

The OpenAI Apps SDK is what OpenAI announced at DevDay in October 2025 to power apps inside ChatGPT (Spotify, Zillow, Canva, Coursera and others launched with it). It builds on MCP as the backend protocol, but made two notable choices: templates are predeclared resources linked from tool metadata, and the widget talks to the host through a window.openai API object injected into the iframe.

MCP Apps (SEP-1865) is the official extension that reconciles the two. Announced in November 2025 on the MCP blog, co-authored by Anthropic, OpenAI, and the MCP-UI maintainers, it adopts the predeclared-template model, keeps the ui:// scheme, and standardizes iframe-host communication as JSON-RPC over postMessage. The MCP-UI SDKs are aligning as the reference implementation, so code you write with @mcp-ui/server today tracks the standard as it evolves.

Practical guidance in 2026: build your server with the MCP-UI server SDK for cross-host portability, add the Apps SDK metadata conventions if ChatGPT is a distribution target, and always return a plain-text answer alongside the widget for hosts that render neither. Support is still uneven across clients, so check your target host's documentation for current status rather than assuming.

Build Your First MCP App in TypeScript

Enough architecture. Let's build a deploy panel: a tool that renders a small dashboard of services with health indicators and a Redeploy button per row, where clicking a button triggers a second tool on the same server. This demonstrates both directions of the data flow in about a hundred lines.

Set up the project:

mkdir deploy-panel && cd deploy-panel
npm init -y
npm install express zod @modelcontextprotocol/sdk @mcp-ui/server
npm install -D typescript tsx @types/express @types/node

Now the server. Save this as server.ts:

import express from "express";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createUIResource } from "@mcp-ui/server";

const SERVICES = [
  { name: "api-gateway", version: "2.14.1", healthy: true },
  { name: "billing", version: "1.9.0", healthy: true },
  { name: "search", version: "3.2.7", healthy: false },
];

function deployPanelHtml(): string {
  const rows = SERVICES.map(
    (s) => `
    <div class="row">
      <span class="dot ${s.healthy ? "ok" : "bad"}"></span>
      <strong>${s.name}</strong>
      <code>${s.version}</code>
      <button data-service="${s.name}">Redeploy</button>
    </div>`
  ).join("");

  return `
  <style>
    body { font-family: system-ui, sans-serif; margin: 0; padding: 12px; }
    .row { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
    .dot { width: 10px; height: 10px; border-radius: 50%; }
    .ok { background: #22c55e; }
    .bad { background: #ef4444; }
    button { margin-left: auto; cursor: pointer; }
  </style>
  <div id="panel">${rows}</div>
  <script>
    document.querySelectorAll("button").forEach((btn) => {
      btn.addEventListener("click", () => {
        window.parent.postMessage(
          {
            type: "tool",
            messageId: crypto.randomUUID(),
            payload: {
              toolName: "redeploy_service",
              params: { service: btn.dataset.service },
            },
          },
          "*"
        );
      });
    });
  </script>`;
}

function buildServer(): McpServer {
  const server = new McpServer({ name: "deploy-panel", version: "1.0.0" });

  server.registerTool(
    "show_deploy_panel",
    {
      title: "Show deploy panel",
      description: "Render an interactive dashboard of deployable services.",
    },
    async () => ({
      content: [
        {
          type: "text",
          text: "Deploy panel: 3 services, search is unhealthy.",
        },
        createUIResource({
          uri: "ui://deploy-panel/v1",
          content: { type: "rawHtml", htmlString: deployPanelHtml() },
          encoding: "text",
        }),
      ],
    })
  );

  server.registerTool(
    "redeploy_service",
    {
      title: "Redeploy a service",
      description: "Queue a redeploy of the named service.",
      inputSchema: { service: z.string() },
    },
    async ({ service }) => ({
      content: [{ type: "text", text: `Redeploy of ${service} queued.` }],
    })
  );

  return server;
}

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const server = buildServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });
  res.on("close", () => {
    transport.close();
    server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000, () => {
  console.log("MCP server listening on http://localhost:3000/mcp");
});

Run it with npx tsx server.ts.

A few things worth noticing in this code:

  • The text content item comes first. Hosts without UI support, and the model itself, get a useful answer either way. Never ship a widget-only tool result.
  • createUIResource wraps the HTML as an embedded resource with the ui:// URI and the right mime type, which is the MCP-UI delivery style. Compatible hosts detect it in the content array and render the iframe; everyone else ignores it gracefully.
  • The widget and the server share a tool vocabulary. The button does not call your infrastructure directly; it asks the host to call redeploy_service, which routes through the same approval and logging path as a model-initiated call.
  • Everything is inlined in one HTML string: styles, markup, script. Host content security policies frequently block external requests from widget iframes, so treat CDN links, external fonts, and analytics beacons as unavailable.

Making the Widget Talk Back

The postMessage in the example used type: "tool", which is one of a small vocabulary of action types a widget can send to its host. The set defined by MCP-UI, carried forward into the standardization work, covers the interaction patterns you actually need in a chat context:

  • tool: ask the host to call a named tool on the server with parameters. The big one. Buttons, form submissions, drag-and-drop reorder handlers all end here.
  • prompt: submit text to the conversation as if the user typed it. Useful for "explain this" or "book the second option" affordances that should route through the model.
  • intent: a semantic action ("user picked seat 14C") that the host translates into whatever its conversation model prefers. Less coupled than a raw prompt string.
  • notify: fire-and-forget signals to the host, most commonly a size-change notification so the iframe can grow and shrink with its content instead of showing scrollbars.
  • link: request navigation to an external URL, which the host opens in a real browser tab, never inside the sandbox.

Requests are asynchronous: attach a messageId and the host sends a response message back into the iframe when the mediated action completes, so you can render optimistic UI and reconcile when the tool result lands. A prompt action looks like this from inside the widget:

window.parent.postMessage(
  {
    type: "prompt",
    payload: { prompt: "Why is the search service unhealthy?" },
  },
  "*"
);

The host decides what actually happens for every one of these messages. That mediation is the security spine of the whole design: a widget can request, but only the host, applying the user's permission settings, can execute.

Testing MCP Apps Locally

You do not need a production chat client to develop MCP apps. Three local options cover the loop.

The fastest is the MCPJam inspector, a community inspector with MCP-UI rendering support:

npx @mcpjam/inspector@latest

Point it at http://localhost:3000/mcp with the streamable HTTP transport, list tools, and invoke show_deploy_panel. The panel renders in the results pane; click a Redeploy button and you will see the mediated redeploy_service call appear in the request log with its response. That round trip (render -> click -> tool call -> result) is the core loop you are debugging, and seeing the raw JSON-RPC messages next to the rendered widget is the best mental-model builder there is.

The ui-inspector project from the MCP-UI authors serves the same purpose with a browser-based harness aimed specifically at widget development. And for an end-to-end test in a real agent, Goose (Block's open-source agent) renders MCP-UI resources in its desktop app and has supported them longer than any mainstream host.

Also test the degraded path on purpose: connect with a plain MCP client that has no UI support (the standard @modelcontextprotocol/inspector works) and confirm your text content still answers the question. If the text fallback reads like "see widget above", rewrite it.

Shipping to ChatGPT with the Apps SDK

ChatGPT is currently the largest surface where MCP apps run in production, and its Apps SDK flavor differs from the raw MCP-UI style in ways you should know before targeting it.

Templates are predeclared, not embedded. You register the widget HTML as a resource, then link tools to it via _meta:

server.registerResource(
  "deploy-panel-widget",
  "ui://widget/deploy-panel.html",
  {},
  async () => ({
    contents: [
      {
        uri: "ui://widget/deploy-panel.html",
        mimeType: "text/html+skybridge",
        text: WIDGET_HTML,
      },
    ],
  })
);

server.registerTool(
  "show_deploy_panel",
  {
    title: "Show deploy panel",
    _meta: { "openai/outputTemplate": "ui://widget/deploy-panel.html" },
    inputSchema: {},
  },
  async () => ({
    content: [{ type: "text", text: "Here is the deploy panel." }],
    structuredContent: { services: SERVICES },
    _meta: { detailedDiagnostics: "widget-only payload lives here" },
  })
);

The tool result splits into three channels with different visibility, and this split is one of the most useful ideas in the whole SDK. content is text the model reads. structuredContent both hydrates the widget and is visible to the model, so keep it small and relevant: it is spending your context window. _meta on the result goes only to the widget, never to the model, which is where bulky payloads like full row data or image lists belong.

Inside the iframe, instead of raw postMessage, the runtime injects window.openai:

const services = window.openai.toolOutput?.services ?? [];

await window.openai.callTool("redeploy_service", { service: "search" });

await window.openai.sendFollowUpMessage({
  prompt: "Summarize current deploy health.",
});

window.openai.setWidgetState({ selected: "search" });

setWidgetState persists widget state across turns and re-renders. requestDisplayMode lets a widget ask to expand from inline to fullscreen or picture-in-picture, which is how map and editor style apps work. During development you connect your server to ChatGPT through developer mode in settings; distribution beyond that goes through OpenAI's app review process, which enforces additional requirements like domain verification and content security policies. Since the platform rules evolve, verify the current submission checklist in the Apps SDK docs before you plan a launch.

The good news for anyone worried about betting on the wrong horse: because both OpenAI and Anthropic co-authored SEP-1865, the Apps SDK conventions and the official extension are converging on the same template-plus-metadata shape. Structuring your server that way today is the safe bet.

Security: What the Sandbox Covers and What It Does Not

Rendering third-party HTML inside a chat client is exactly as dangerous as it sounds, and the extension's design spends most of its complexity budget on containing that danger. Know what the protections cover and where your responsibilities as a server author begin.

What the architecture gives you:

  • Process-level isolation. The iframe sandbox denies same-origin access, so widget scripts cannot read the host DOM, host cookies, or the user's session with the chat client.
  • Mediated capability access. A widget has no ambient authority. Every tool call, prompt, or navigation is a request to the host, subject to the same user consent flows as model-initiated actions. Consequential tools should still be confirmed by the host UI, because a click on a button labeled Cancel could send any payload the widget author chose.
  • Reviewable templates. Predeclared ui:// resources mean the HTML a host renders is stable and cacheable, not synthesized per response, which gives hosts and marketplaces something concrete to audit and pin.
  • Auditable traffic. Because iframe-host communication is JSON-RPC, hosts can log and inspect every message crossing the boundary.

What still lands on you:

  • Widget input is untrusted input. Parameters arriving at your tools from a widget click are client-side data. Validate them server-side with your schema exactly as you would an API request body. The z.string() in the example is doing real security work.
  • Prompt injection cuts both ways. Text your widget sends via prompt or intent actions enters the model's context. A compromised or malicious widget can try to steer the conversation, which is why hosts attribute widget-originated messages and why you should never render untrusted third-party content inside your own widget without sanitizing it.
  • Data exfiltration budgets. Whatever network access the host CSP does allow to your declared domains is a channel; minimize it, and never put secrets, API keys, or user tokens inside a template that is fetched and cached by clients.
  • Idempotency. Users double-click. Networks retry. A redeploy_service that queues two deploys when clicked twice is a bug you will meet in week one; make mutating tools idempotent with client-supplied operation keys where it matters.

Design Rules for UI That Lives in a Chat

MCP apps fail in a characteristic way: teams port a full web dashboard into an iframe and wonder why users hate it. A widget inside a conversation is a different medium with its own grammar, and the teams shipping good MCP apps in 2026 have converged on a consistent set of rules.

  • Answer first, interface second. The text content should resolve the user's question on its own; the widget accelerates the follow-up action. If the widget disappeared, the turn should still make sense.
  • One job per widget. A seat picker picks seats. A deploy panel deploys. Navigation hierarchies, settings pages, and profile editors belong in your product, one link action away.
  • Design for the inline size class by default. Assume roughly card height in a chat column, glanceable in two seconds, and escalate to fullscreen only for genuinely spatial work like maps, boards, or document editing.
  • Let the host own the conversation. Do not build chat inside your widget. If the user wants to say something, route it through prompt or intent actions so the model keeps a complete picture of the interaction.
  • Assume the iframe is disposable. Hosts recreate widgets on scroll, on re-render, on conversation reload. Persist anything that matters through widget state APIs or tool round-trips, never in module-level variables or localStorage.
  • Keep the model-visible payload lean and the widget-only payload rich. Context windows are expensive; iframe memory is cheap.
  • Inline every asset and respect both themes. One self-contained HTML file, system font stack, colors that survive light and dark host chrome.
  • Instrument the fallback path. Some fraction of your users are on hosts without UI support, and that fraction is invisible unless you measure which content path served the answer.

FAQ

Do MCP apps work in Claude?

Anthropic co-authored the MCP Apps extension and has committed to supporting it across its surfaces, with rollout landing incrementally rather than all at once. Check the current Claude and Claude Desktop documentation for the state of UI rendering support in your target surface, and keep the text fallback path solid so your server is useful there regardless.

What is the difference between MCP apps and ChatGPT apps?

ChatGPT apps are OpenAI's productized experience built with the Apps SDK: MCP servers plus predeclared widget templates, the window.openai iframe API, and OpenAI's review and distribution pipeline. MCP apps (SEP-1865) is the vendor-neutral extension standardizing the same underlying pattern for every MCP host. A well-structured server can target both with one codebase and a thin metadata layer.

Do I need React to build an MCP app?

No. The contract is an HTML document in a sandboxed iframe, so vanilla HTML plus a script tag is a complete implementation, and it is the right call for simple widgets. React, Svelte, and friends are fine for complex widgets as long as your build emits a single self-contained file with everything inlined.

Can a widget call any tool on my server?

A widget can request any tool by name, but the host mediates every call and applies the same permission model as model-initiated calls, and your server still validates parameters. Treat the widget as an untrusted client of your server, not as part of it.

How are MCP apps different from artifacts?

Artifacts are generated by the model at conversation time: the model writes code, the client previews it, and no external server is involved. An MCP app is authored by a developer, shipped and versioned as part of an MCP server, bound to that server's tools, and rendered from a predeclared template. Artifacts are improvisation; MCP apps are product surface.

Does MCP-UI still matter now that MCP Apps is official?

Yes. MCP-UI is the reference implementation lineage for the extension, its maintainers co-authored SEP-1865, and the @mcp-ui/server and @mcp-ui/client packages are the practical way to build against the standard today, including capabilities (like external URL embeds and remote DOM) that the first standardized version does not cover yet.

Can I build MCP apps in Python?

Server-side, yes: a UI template is just a string resource, so any MCP server SDK can serve one, and the MCP-UI project ships helper packages beyond TypeScript, including Python and Ruby. The client-side story is unchanged either way, since the widget itself is always web content.

Where to Go Next

The fastest path to a working mental model is the one this article walked: run the deploy panel server, open it in the MCPJam inspector, and watch the JSON-RPC messages flow as you click. Then read SEP-1865 in the modelcontextprotocol spec repository to see the formal version of what you just observed, skim the MCP-UI documentation for the client and server API surface, and look at the OpenAI Apps SDK examples for production-grade widget patterns like display modes and widget state.

Start with one tool that genuinely benefits from a face: a picker, a status board, a confirmation card. Ship it with a text fallback you would be happy to receive yourself. The chat window is becoming an operating surface, and MCP apps are the standard way to put your product inside it.

MCP Apps: Interactive UI Inside AI Chat Clients · TeachYou Academy