Skip to content
Malik Hamza Shabbir
MCP & Agent Protocolsmcpmcp appssep-1865interactive ui

Shipping Your First MCP App: Rendering Interactive UI Inside Claude and ChatGPT (SEP-1865)

HSMalik Hamza ShabbirUpdated 10 min read

In short

An MCP App is a sandboxed UI your server hands the host (Claude or ChatGPT), wired so every user click becomes an audited tools/call. You declare the UI as a ui:// resource, attach it to a tool, seed it with render data, and handle the callback. As of the 2026-07-28 spec, Apps are an official extension, so clients render them reliably. See my stateless migration guide for the matching transport changes.

Shipping Your First MCP App: Rendering Interactive UI Inside Claude and ChatGPT (SEP-1865)
On this page

If you want to ship your first MCP App, the short version is this: you declare a UI resource on your MCP server, the host (Claude or ChatGPT) renders it inside a sandboxed iframe, and every action a user takes in that UI travels back through the same JSON-RPC channel your tool calls already use. Nothing in the UI gets to touch the model or your backend without going through that audited path. As of the 2026-07-28 spec, MCP Apps are no longer experimental, they are an official extension, which is why clients started rendering them and why the tutorial searches are spiking.

I have built a few of these now, and the mental model that finally made it click for me was simple: an MCP App is not a plugin that runs your code inside the assistant. It is a tiny website your server hands over, rendered in a locked-down box, that can only talk to the assistant by sending it structured messages. Once you hold that picture, the rest is plumbing.

What is an MCP App and how is it different from a normal MCP tool?

An MCP App is an MCP server that, in addition to returning text or JSON from a tool call, can return an interactive HTML UI that the host renders inline in the chat. A normal tool gives the model words back. An App gives the user something to click.

The split matters because they serve different audiences in the same exchange. A tool result is consumed by the model so it can reason and reply. An App result is consumed by the human, who sees a real interface (a form, a chart, a date picker, a checkout) embedded right under the assistant's message.

The trick that makes this safe and composable is that the App does not replace the tool path. It rides on top of it. Your UI is just another resource your server exposes, and the interactions inside it are routed as JSON-RPC messages, the exact same transport that carries tools/call. So an App is a tool result that happens to be a renderable surface, plus a return channel for what the user does with it.








How do I declare a UI resource on my MCP server?

You declare the UI as a resource with an HTML content type, then point a tool at it so the host knows to render that resource when the tool runs. The resource is the template, the tool result wires in the data.

In practice you register the UI once and reference it from the tool. Here is the shape of it using the TypeScript SDK. I have kept it deliberately small so the moving parts are visible.

TS
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const server = new McpServer({ name: "trip-planner", version: "1.0.0" });

// 1. The UI resource: a tiny self-contained web app
server.registerResource(
  "trip-form-ui",
  "ui://trip-planner/form",
  { mimeType: "text/html+mcp" },
  async () => ({
    contents: [{
      uri: "ui://trip-planner/form",
      mimeType: "text/html+mcp",
      text: TRIP_FORM_HTML, // your bundled HTML/JS string
    }],
  })
);

// 2. A tool that renders that UI and seeds it with data
server.registerTool(
  "plan_trip",
  {
    description: "Open the interactive trip planner",
    inputSchema: { destination: z.string() },
    _meta: { "mcp/ui": { resourceUri: "ui://trip-planner/form" } },
  },
  async ({ destination }) => ({
    content: [{ type: "text", text: `Opening planner for ${destination}` }],
    _meta: { "mcp/ui": { data: { destination, currency: "USD" } } },
  })
);

Two things to notice. First, the UI lives at a ui:// URI with an MCP-specific HTML mime type, so the host knows this resource is meant to be rendered, not read aloud. Second, the tool result carries a _meta payload that the host injects into the iframe at render time. That is how your form shows up pre-filled with the destination the model already parsed. The exact _meta key names are still settling as SDKs catch up to the formalized spec, so check your SDK version, but the structure (resource + render directive + seed data) is the stable part.

How does the sandboxed-iframe security model actually work?

The host renders your UI inside a sandboxed iframe with no direct access to the model, the conversation, the user's tokens, or your backend. The iframe can only communicate by posting structured messages to the host, which means a compromised or malicious UI cannot quietly exfiltrate anything.

This is the part I would slow down on, because it is where most people's instincts about "embedding a web app" are wrong. The iframe is treated as hostile by design. It runs with a restrictive sandbox, typically allowing scripts but blocking same-origin access, top-navigation, and direct network calls to arbitrary hosts. Your UI does not get a fetch line to your own API. If it wants data, it asks the host, and the host decides.

The contract between the iframe and the host is window.postMessage. Your UI posts a message up, the host validates it, and if it is a request to run a tool, the host routes it through the normal MCP machinery with the user's existing consent rules. Nothing about the App bypasses the trust boundary that already governs tool calls.

JS
// Inside the sandboxed iframe (your UI bundle)
window.parent.postMessage({
  type: "tool/call",
  payload: { name: "book_trip", arguments: { destination, dates } }
}, "*");

// Receiving seed data and results back from the host
window.addEventListener("message", (event) => {
  const msg = event.data;
  if (msg.type === "render/data") hydrate(msg.payload);
  if (msg.type === "tool/result") showConfirmation(msg.payload);
});

A few rules I follow and would push on any team shipping these:

  • Treat the iframe as untrusted on the host side, and treat the host as the only trusted peer on the iframe side. Validate event.origin and message shape on both ends.

  • Never put secrets in the HTML bundle. It is shipped to the client and rendered in a box you do not control. API keys belong on the server, behind tool calls.

  • Keep the UI dumb. It collects intent and displays results. The server decides what is allowed.


If you want the deeper threat picture on the server side of all this, I wrote a companion piece on auditing a third-party MCP server before you trust it , and the iframe model is the front-end mirror of those same concerns.

How do UI actions get audited the same way as tool calls?

Because every action a user takes in the App is converted by the host into a real tools/call request, it lands in the exact same audit, consent, and logging path as a tool the model would have invoked itself. There is no separate, weaker channel for "UI clicks."

This is the single most important design decision in SEP-1865, in my opinion, and it is the reason I trust Apps in a way I would not trust an arbitrary embedded webview. When a user clicks "Book" in my trip planner, the iframe does not book anything. It posts an intent. The host turns that intent into a tools/call for book_trip, applies whatever approval policy the user has configured (auto-approve, ask every time, blocked), and only then does my server's tool handler run.

So the audit trail is unified. Whether the model decided to call book_trip or the human clicked a button that called book_trip, you see one consistent record: same tool name, same arguments schema, same consent gate, same log line. For anyone who has to answer "what did this agent actually do," that single path is worth a lot. It also means your existing server-side validation, rate limits, and authorization checks on the tool cover the UI for free, because the UI cannot reach the handler any other way.

TS
// One handler. Reached by the model OR by a user UI click. Audited identically.
server.registerTool("book_trip", { /* schema */ }, async (args, ctx) => {
  await assertUserCanBook(ctx.session);   // authz runs regardless of caller
  const booking = await db.createBooking(args);
  return { content: [{ type: "text", text: `Booked ${booking.id}` }] };
});

What changed when Apps and Tasks moved from experimental to official?

The headline change is status and stability. MCP Apps was announced as an official extension on 26 January 2026 and then formalized in the 2026-07-28 spec, which means clients can rely on it and render Apps without treating the feature as a moving experiment.

In practice, "experimental to official extension" changed three things for me as a builder:

  1. Clients started rendering Apps for real. Before, you were building against a preview that a host might or might not support, and might break next release. Now the UI resource contract is something hosts commit to. That is the actual reason tutorial demand jumped: the surface finally renders in the products people use.

  2. The interaction model got pinned down. The "UI action becomes a tools/call" routing, the sandboxed-iframe expectations, and the resource declaration are no longer suggestions. They are part of the spec you can build against and test against.

  3. It rides the same 2026-07-28 baseline as the broader transport changes. If you are also moving servers to the new stateless model, the two efforts touch the same release, and I would do them together. I covered that side in my stateless 2026-07-28 migration field guide .


One honest caveat on "Tasks." Apps and Tasks tend to get mentioned in the same breath because they graduated around the same time, but they solve different problems. Apps are about rendering interactive UI. Tasks are about long-running, resumable work that outlives a single request. If your App kicks off something slow (rendering a report, processing an upload), that slow part is a Task, and the UI just reflects its progress. Keep the two ideas separate in your head even when the spec ships them together.

What is the minimum end-to-end build, start to render?

The shortest honest path is five steps: build a UI bundle, expose it as a ui:// resource, attach it to a tool, return seed data from that tool, and handle the tools/call that comes back when the user interacts. That is the whole loop.

Here is the checklist I actually run through, in order:







AspectPlain MCP toolMCP App (SEP-1865)
What it returnsText / structured JSONA UI resource + structured data
Who consumes itThe modelThe human user, in a rendered iframe
Runs whereServer-sideSandboxed iframe in the host
User interactionNoneClicks/forms post back as JSON-RPC
Status in specCore since launchOfficial extension as of 2026-07-28
Audit pathtools/call logSame tools/call log
StepWhat you doWatch out for
1. Bundle the UIBuild self-contained HTML/JS, no external fetchesInline assets; no CDN dependency the sandbox blocks
2. Register resourceServe it at ui://... with the MCP HTML mime typeWrong mime type means the host reads it, not renders it
3. Link to toolReference the resource URI from the tool's _metaSDK key names vary by version, pin your SDK
4. Seed dataReturn render data in the tool result _metaKeep it small and JSON-serializable
5. Handle callbacksImplement the tool the UI calls on interactionRun authz in the handler, not the iframe

If your App is more than a single form, the same discipline you would apply to a packaged capability applies here, and a lot of it overlaps with how I think about portable agent skills with SKILL.md : keep the contract explicit, keep the logic server-side, and assume the runtime is not yours.

For most teams the build itself is a day or two. The week goes into the boring parts: validating message origins, deciding consent policy, and making the UI degrade gracefully when the host renders it narrow. That front-end-meets-protocol work is exactly the kind of thing I do in my web development work , and it is where the polish lives.

Should I ship an MCP App, or just return structured data?

Ship an App when the user genuinely needs to do something interactive that text cannot capture: pick from a map, confirm a multi-step booking, adjust a chart, fill a form with validation. If the model can just say the answer, skip the App and return text or structured JSON.

I have talked clients out of Apps more than once. The interactive surface is a real maintenance commitment: a bundle to ship, a sandbox to respect, a message contract to keep in sync. If your interaction is "show me three options and let me confirm one," a well-structured text response with a follow-up tool is often enough and far cheaper to keep alive. Apps earn their keep when the interaction is irreducibly visual or has enough state that a chat turn would be clumsy.

If you are weighing whether your use case justifies one, or you want a second set of eyes on the security model before you put it in front of users, that is a quick conversation. Reach out through my contact page and tell me what the App needs to do. I would rather help you decide than watch you build a surface you do not need.

The bottom line: an MCP App is a sandboxed UI your server hands the host, wired so that every click becomes an audited tool call. Get the resource declaration, the iframe boundary, and the unified tools/call path right, and the rest is the same web build you already know how to do.

FAQ

What is an MCP App in SEP-1865?

An MCP App is an MCP server that returns an interactive HTML UI which the host renders inline in chat, in addition to the usual text or JSON a tool returns.

How does the sandboxed iframe stay secure?

The UI runs in a restrictive sandbox with no direct model, network, or backend access, and can only communicate by posting structured messages the host validates.

How do UI actions get audited?

Every user action in the App is converted by the host into a real tools/call, so it lands in the same consent, authorization, and logging path as a model-initiated tool call.

When did MCP Apps become official?

MCP Apps was announced as an official extension on 26 January 2026 and formalized in the 2026-07-28 specification, which is when clients began rendering Apps reliably.

Should I build an MCP App or just return data?

Build an App only when the user needs genuinely interactive UI like forms, maps, or charts, and otherwise return structured text since it is far cheaper to maintain.

Working on something like this?

I build web apps, AI features, and mobile products for clients. If this article matches a problem you have, tell me about it.

Start a conversation
HS

Malik Hamza Shabbir · Full-Stack & AI Engineer

I build full-stack and AI products solo: a reputation SaaS in production, RAG pipelines, and React Native apps. I write from what I ship, not from documentation summaries.

Related articles