
# MCP Apps

> Return an interactive interface instead of a wall of text.

MCP Apps (`io.modelcontextprotocol/ui`, [SEP-1865](https://modelcontextprotocol.io/extensions/apps/overview)) 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.

```ts
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:

```json
// 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.

```ts
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.

```json
"_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](/protocols/modern/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:

```ts
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 {#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:

```ts
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](/protocols/modern/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

```ts
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).
