Short version: An MCP App lets your server hand the host a real HTML interface — a chart, a form, a date picker — that renders inline in the conversation, instead of making the model describe the result in prose. It's standardized as SEP-1865, it went live as an official MCP extension on 2026-01-26, and it rides the Extensions framework (SEP-2133) that lands in the final 2026-07-28 spec. To build one you register a UI resource under the ui:// scheme, bind a tool to it with a nested _meta.ui.resourceUri, and let the SDK carry UI↔server messages over MCP's JSON-RPC on postMessage. This is the working walkthrough — with the current spec values, not the ones that changed under everyone's feet.

We've covered the stateless core of the 2026-07-28 spec and what MCP Apps are as a concept. This is the hands-on companion: what you actually type to ship one.

What an MCP App is (and isn't)#

A normal MCP tool returns content the model consumes and re-narrates. That's the right shape when the agent needs to reason over the result. It's the wrong shape when a human needs to see or manipulate the result — a sortable table, a plotted time series, an approval form with two buttons. Prose descriptions of a UI are a bad UI.

An MCP App fixes that. Your server declares an HTML/JS bundle as a UI resource, binds it to a tool, and when the host runs that tool it renders your bundle in a sandboxed iframe right in the conversation. The iframe can call back into your server's tools and resources — so the chart you drew can fetch the next page, and the form you rendered can submit.

Prose descriptions of a UI are a bad UI. MCP Apps let the server ship the real thing.

The two values that changed — get these right first#

Most of the broken examples you'll find on the web predate the shipped spec. Two things moved between the November 2025 proposal and the version in the release candidate, and using the old values fails quietly:

Do that and half the "why won't my UI render" threads never happen.

Step 1 — register the UI resource#

The UI resource is just bundled HTML/JS served under the ui:// scheme. Build your view however you like (vanilla, React, Svelte — the SDK ships bindings), then register the built bundle:

import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,           // "text/html;profile=mcp-app"
} from "@modelcontextprotocol/ext-apps/server";
import fs from "node:fs/promises";
import path from "node:path";

const resourceUri = "ui://get-time/view";

// The resource body is your compiled single-file HTML bundle.
registerAppResource(
  server, resourceUri, resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => ({
    contents: [{
      uri: resourceUri,
      mimeType: RESOURCE_MIME_TYPE,
      text: await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"),
    }],
  }),
);

Step 2 — bind a tool to it#

A tool becomes an App by pointing its _meta.ui.resourceUri at the resource you just registered. Everything else about the tool — name, description, input schema, handler — stays exactly as it was:

registerAppTool(
  server, "get-time",
  {
    title: "Get Time",
    description: "Returns the current server time.",
    inputSchema: {},
    _meta: { ui: { resourceUri } },   // <-- nested, not the deprecated flat key
  },
  async () => ({
    content: [{ type: "text", text: new Date().toISOString() }],
  }),
);

Now when the host runs get-time, it fetches ui://get-time/view and renders your bundle in a sandboxed iframe instead of just printing the ISO string.

Step 3 — talk back from inside the iframe#

Inside the bundle, your UI is an MCP client. The App class handles the JSON-RPC-over-postMessage handshake so you don't touch raw postMessage. You call server tools and subscribe to results:

import { App } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "Get Time App", version: "1.0.0" });

app.ontoolresult = (result) => {
  const time = result.content?.find((c) => c.type === "text")?.text;
  document.querySelector("#clock")!.textContent = time ?? "—";
};

// Re-fetch on a button click, straight from the UI:
document.querySelector("#refresh")!.addEventListener("click", () => {
  app.callServerTool({ name: "get-time", arguments: {} });
});

One caveat worth flagging: the simplified new App({ name, version }) and bare connect() in the quickstarts are illustrative. The real App constructor takes (appInfo, capabilities, options) and connect() takes a PostMessageTransport. Pin the SDK version (latest at writing is v1.1.2) and reconcile the exact signatures against its types before you ship — the concepts above are stable, the constructor ergonomics are still settling.

Step 4 — respect the security model#

MCP Apps run untrusted-ish HTML inside a chat client, so the spec is deliberately defense-in-depth. You get it mostly for free, but know what you're relying on:

Treat a UI resource the way you'd treat any third-party embed: keep the bundle small, scope what it can call, and don't smuggle secrets into the HTML.

When to reach for it#

Reach for an MCP App when the human is the consumer of the tool's output: dashboards, approval flows, anything with a chart or a form. Keep it a plain tool when the agent is the consumer and text is enough — an App there is just latency and attack surface you didn't need. For the "too many tools" version of that same judgment call, see tool search vs. code execution; and if you're deciding between MCP Apps and A2A's UI story, we compared them in A2UI vs. MCP Apps.