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:
| Configuration | tools/list | resources/list | server/discover |
|---|---|---|---|
| an app claiming at least one tool | private | private | private |
an app with no tools | untouched | private | private |
apps: [], or enabled: false | untouched | untouched | untouched |
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:
| Rejected | Why |
|---|---|
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 parse | resources/read matches the parsed URI exactly, so it would never be readable |
| two apps on one URI | the second would be listed and never served |
| one tool claimed by two apps | resourceUri 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).