MCP Apps

Return an interactive interface instead of a wall of text.

MCP Apps (io.modelcontextprotocol/ui, SEP-1865) lets a tool declare an HTML view that the host renders inline, in place of the tool's result. The pattern is two primitives you already have: a tool that points at a UI resource, and a resource that serves the HTML.

mcpUI() does both from one declaration.

import { z } from "zod";
import { defineMcpHandler, defineTool } from "h3-mcp";
import { canRenderApp, defineApp, mcpUI } from "h3-mcp/ui";

const dashboard = defineApp({
  uri: "ui://weather/dashboard", // the ui:// scheme is what marks it as an app
  name: "weather_dashboard",
  description: "Interactive weather dashboard",
  html: () => readFile(new URL("dist/app.html", import.meta.url), "utf8"),
  tools: ["get_weather"], // these tools render into it
});

const getWeather = defineTool({
  name: "get_weather",
  inputSchema: z.object({ city: z.string() }),
  // The view renders `structuredContent`, so the shape the app can draw *is*
  // the tool's contract — declaring it means a result the view cannot render
  // is rejected here rather than blanking the iframe.
  outputSchema: z.object({ tempC: z.number(), summary: z.string() }),
  handler: async ({ city }, event) => {
    const forecast = await lookup(city);
    return {
      // Always meaningful text: it is what the model reads, and it is all a
      // host without MCP Apps support will ever get.
      content: [
        { type: "text", text: canRenderApp(event) ? "Forecast loaded." : asText(forecast) },
      ],
      // What the view renders from.
      structuredContent: forecast,
    };
  },
});

export default defineMcpHandler(
  { name: "my-server", version: "1.0.0", tools: [getWeather] },
  { extensionPlugins: [mcpUI({ apps: [dashboard] })] },
);

Installing the plugin is what serves the extension: HandlerOptions has no apps option, so the app list and the code that serves it cannot disagree. Nothing is pre-installed on any entrypoint.

#What reaches the wire

For a client that declared it can render an app, and only for that client:

// tools/list
{ "name": "get_weather", "inputSchema": { … },
  "_meta": { "ui": { "resourceUri": "ui://weather/dashboard" } } }

// resources/list
{ "name": "weather_dashboard", "uri": "ui://weather/dashboard",
  "mimeType": "text/html;profile=mcp-app" }

// resources/read { "uri": "ui://weather/dashboard" }
{ "contents": [{ "uri": "ui://weather/dashboard",
                 "mimeType": "text/html;profile=mcp-app",
                 "text": "<!DOCTYPE html>…" }] }

The host reads the tool's _meta.ui.resourceUri, fetches that resource — often before the tool is called, so the view is already on screen — renders it in a sandboxed iframe, and pushes the tool result into it when it settles.

#Graceful degradation is the default, not an option

A client that did not declare io.modelcontextprotocol/ui, or declared it without a content type this server can serve, sees none of the above: no _meta.ui, no ui:// resource in resources/list, and a not-found on a resources/read for it. What it sees is exactly what a server with no plugin installed would serve.

That is half the spec's fallback rule. The other half is yours: a UI-enabled tool MUST still return meaningful content, because the model reads it even when a view is rendered. canRenderApp(event) is the pre-flight for that decision.

handler: (args, event) =>
  canRenderApp(event)
    ? { content: [{ type: "text", text: "Chart rendered." }], structuredContent: rows }
    : { content: [{ type: "text", text: renderAsAsciiChart(rows) }], structuredContent: rows };

Negotiation is a value test: the client's settings object carries mimeTypes, and the server serves an app only when it lists text/html;profile=mcp-app exactly.

"_meta": {
  "io.modelcontextprotocol/clientCapabilities": {
    "extensions": { "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] } }
  }
}

Because that is per request, one client can get the app while the next gets plain text from the same server — see Extensions for the negotiation itself.

#The view

html takes a finished HTML5 document: a string, or a callback resolved on each resources/read (useful for reading it off disk lazily). h3-mcp does no bundling. Hosts render the document in a sandboxed iframe under a deny-by-default CSP, so the usual shape is a single file with its script and styles inlined — vite-plugin-singlefile and friends produce exactly that. Anything loaded from elsewhere has to be declared:

defineApp({
  uri: "ui://weather/dashboard",
  name: "weather_dashboard",
  html: dashboardHtml,
  tools: ["get_weather"],
  csp: {
    connectDomains: ["https://api.openweathermap.org"], // fetch / XHR / WebSocket
    resourceDomains: ["https://cdn.jsdelivr.net"], // scripts, styles, images, fonts
    frameDomains: [], // nested iframes — omitted means none
  },
  permissions: { geolocation: {} }, // requested, not guaranteed: feature-detect
  prefersBorder: true, // host defaults vary; say what you want
  domain: "abc123.example-content.com", // a dedicated sandbox origin, if the host defines one
});

Every CSP field is a grant. Omitted means none, not unrestricted — so declare an origin only if the view actually reaches it. A view whose data is fetched by the server (the ordinary MCP shape) needs no connectDomains at all, and keeping the fetch in the handler is usually the better call anyway: the text a text-only host gets is what the model reads, so a view-side fetch leaves the model with different data than the human. playground/mcp/apps/weather.ts is that shape end to end, down to inlining its icons as SVG so no resourceDomains grant is needed either.

permissions is the orthogonal axis — a browser capability for the iframe, not an origin allowance — and it is a request: a host may ignore it and a user may refuse, so the view has to feature-detect (the same file shows the geolocation case, where a denial simply leaves the city picker as it was).

Inside the iframe, the view talks to its host over postMessage in a separate dialect of MCP (ui/initialize, ui/notifications/tool-result, ui/open-link, …). None of it reaches this server — when the view calls a tool, the host forwards a perfectly ordinary tools/call. No SDK is required; playground/mcp/apps/chart.ts is a hand-written view in about 60 lines, and playground/mcp/apps/weather.ts is the same protocol with user-chosen arguments on the tools/call it sends.

The playground also ships the other end of that conversation, in playground/src/appframe.jsx — a working host in ~450 lines, if you want to see what your app will be rendered by: the sandboxed iframe, the CSP assembled from your csp declaration, the handshake, and the tools/call proxy with visibility enforced.

#Visibility

A tool can exist for the app and not for the model — a refresh button, a drill-down — by declaring who may reach it:

tools: ["get_weather", { name: "refresh_weather", visibility: ["app"] }];

["model", "app"] is the host's default when omitted. The host enforces this: it owns both the tool list shown to the model and the bridge an app calls through. h3-mcp declares the value and still serves the tool over tools/call, because only the host knows whether a call came from an app. The playground's own host is the worked example — it drops app-only tools from the catalog it lists and refuses a tools/call from a view for any tool that view's app did not claim.

#Caching

mcpUI() makes what a client sees depend on what it declared, so it forces cacheScope: "private" on the results it can vary — including the plain, un-stamped variant, which is just as client-specific. ttlMs is untouched, and caching you configured elsewhere (prompts/list, an ordinary resources/read) is left alone.

The downgrade is per result kind, and only where something can actually differ:

Configurationtools/listresources/listserver/discover
an app claiming at least one toolprivateprivateprivate
an app with no toolsuntouchedprivateprivate
apps: [], or enabled: falseuntoucheduntoucheduntouched

So a cache: { tools: { cacheScope: "public" } } you configured survives installing an app that no tool renders into.

Reading an app's own ui:// resource is not downgraded: it serves one static document to every host that can render it, and a host that cannot is refused the URI with an error, which carries no caching hints at all. Its caching is yours to set with defineApp({ cache }), like any other resource.

#Gating the extension

mcpUI({ apps: [dashboard], enabled: (event) => isBeta(event) });

enabled: false turns the extension off entirely: nothing advertised, nothing stamped, no caching change. A predicate is evaluated per request — though with static handler options the identifier is still advertised on server/discover, since there is no request to judge at mount. Pass (event) => options to the handler to make the advertisement exact too.

#Mount-time checks

mcpUI() throws a TypeError at construction — not at request time — for anything knowable then:

RejectedWhy
a URI that is not ui://the scheme is what identifies an app to a host
ui://a/../b, or a URI that does not parseresources/read matches the parsed URI exactly, so it would never be readable
two apps on one URIthe second would be listed and never served
one tool claimed by two appsresourceUri is a single value

A tool name that no definition matches is not an error: definitions resolve per request and may be dynamic, so the stamp simply never lands.

#Not implemented

The deprecated flat _meta["ui/resourceUri"] (the spec removes it before GA) and the externalUrl / text/uri-list content type (deferred from the MVP by the spec itself).

h3-mcp  MCP servers, built on H3.