{"protocol":"clank-doc/1","frameworkVersion":"0.19.5","slug":"mcp-apps","title":"Interactive MCP Apps","description":"Clank can render an application's typed server actions as interactive views inside MCP hosts. An MCP App is still an ordinary tool with a useful text and structured data result. It additionally points to an immutable ui:// HTML resource tha","group":{"id":"agents","title":"Agents and generation"},"url":"https://docs.clank.run/docs/mcp-apps","source":"docs/mcp-apps.md","headings":["Build a view","Bind it to backend actions","App only actions","Host context and actions","Security policy","Low level MCP servers","Verify with MCPJam"],"tableOfContents":[{"id":"build-a-view","title":"Build a view","level":2},{"id":"bind-it-to-backend-actions","title":"Bind it to backend actions","level":2},{"id":"app-only-actions","title":"App-only actions","level":2},{"id":"host-context-and-actions","title":"Host context and actions","level":2},{"id":"security-policy","title":"Security policy","level":2},{"id":"low-level-mcp-servers","title":"Low-level MCP servers","level":2},{"id":"verify-with-mcpjam","title":"Verify with MCPJam","level":2}],"markdown":"# Interactive MCP Apps\n\nClank can render an application's typed server actions as interactive views inside MCP hosts.\nAn MCP App is still an ordinary tool with a useful text and structured-data result. It additionally\npoints to an immutable `ui://` HTML resource that a compatible host renders in a sandboxed iframe.\nClients without MCP Apps support keep the normal text fallback.\n\nClank implements the [stable `2026-01-26` MCP Apps extension](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)\nwithout adding a package dependency.\nIt provides:\n\n- `defineMcpApp()` for validated immutable HTML resources;\n- `createMcpAppDocument()` for a standalone HTML5 view with the bridge runtime inlined;\n- `agent.app` for binding one shared view to backend queries and mutations;\n- durable `_meta.ui.resourceUri` metadata on model-visible tools plus negotiated app-only tools;\n- exact `text/html;profile=mcp-app` resources through `resources/list` and `resources/read`;\n- CSP, browser-permission, dedicated-domain, and border-preference declarations; and\n- `createMcpAppClient()` for tool calls, resource reads, host context, theming, display modes,\n  sizing, messages, downloads, and teardown over the standard iframe JSON-RPC channel.\n\nThere is no second API. Calls made by the embedded view go through the MCP host to the same Clank\nquery or mutation used by the browser and model.\n\n## Build a view\n\nDefine the view once, outside the backend function tree:\n\n```ts\nimport {\n  createMcpAppDocument,\n  defineMcpApp,\n} from \"@clank.run/framework\";\n\nexport const todoBoard = defineMcpApp({\n  uri: \"ui://todos/board\",\n  name: \"todo_board\",\n  title: \"Todo board\",\n  description: \"Interactive todos for the signed-in user.\",\n  prefersBorder: true,\n\n  // Omit CSP domains when the view uses only its inline code and host-proxied\n  // MCP calls. The host then applies the restrictive specification default.\n  csp: {\n    connectDomains: [],\n    resourceDomains: [],\n    frameDomains: [],\n    baseUriDomains: [],\n  },\n\n  html: createMcpAppDocument({\n    title: \"Todo board\",\n    body: `\n      <main>\n        <header><h1>Todos</h1><span id=\"status\">Connecting…</span></header>\n        <form id=\"add-form\">\n          <input id=\"title\" maxlength=\"160\" aria-label=\"New todo\" required>\n          <button>Add</button>\n        </form>\n        <ul id=\"todos\"></ul>\n      </main>\n    `,\n    styles: `\n      :root { color-scheme: light dark; font-family: var(--font-sans, system-ui); }\n      body { margin: 0; color: var(--color-text-primary, CanvasText); background: transparent; }\n      main { display: grid; gap: 12px; padding: 16px; }\n      header, form, li { display: flex; align-items: center; gap: 8px; }\n      header { justify-content: space-between; }\n      h1 { margin: 0; font-size: var(--font-heading-md-size, 20px); }\n      input { min-width: 0; flex: 1; }\n      ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }\n      li { padding: 10px; border: 1px solid var(--color-border-secondary, #8885); border-radius: var(--border-radius-md, 8px); }\n      li span { flex: 1; }\n    `,\n    script: `\n      const api = globalThis.ClankMcpApp;\n      const status = document.querySelector(\"#status\");\n      const list = document.querySelector(\"#todos\");\n\n      const render = (value) => {\n        list.replaceChildren();\n        for (const todo of value ?? []) {\n          const item = document.createElement(\"li\");\n          const label = document.createElement(\"span\");\n          label.textContent = todo.title;\n          const toggle = document.createElement(\"button\");\n          toggle.textContent = todo.done ? \"Reopen\" : \"Complete\";\n          toggle.addEventListener(\"click\", async () => {\n            const result = await client.callTool(\"todos_toggle\", {\n              id: todo._id,\n              version: todo._version,\n            });\n            render(result.structuredContent?.value);\n          });\n          item.append(label, toggle);\n          list.append(item);\n        }\n      };\n\n      const client = api.createMcpAppClient({\n        name: \"todo-board\",\n        availableDisplayModes: [\"inline\", \"fullscreen\"],\n        onHostContext(context) {\n          api.applyMcpAppTheme(context);\n        },\n        onToolResult(result) {\n          render(result.structuredContent?.value);\n        },\n      });\n\n      document.querySelector(\"#add-form\").addEventListener(\"submit\", async (event) => {\n        event.preventDefault();\n        const input = document.querySelector(\"#title\");\n        const result = await client.callTool(\"todos_add\", { title: input.value });\n        input.value = \"\";\n        render(result.structuredContent?.value);\n      });\n\n      client.connect().then(async () => {\n        status.textContent = \"Connected\";\n        const result = await client.callTool(\"todos_list\", {});\n        render(result.structuredContent?.value);\n      }).catch(() => {\n        status.textContent = \"Could not connect\";\n      });\n    `,\n  }),\n});\n```\n\n`createMcpAppDocument()` embeds the Clank bridge directly. The resource does not import a CDN\nmodule and does not depend on a regular web route remaining online. `body`, `styles`, and `script`\nare trusted application source—not user input. Dynamic labels use `textContent` in the example so\nserver data never becomes executable markup.\n\n## Bind it to backend actions\n\nAttach the same view to every action whose result should update it:\n\n```ts\nexport const backend = defineBackend({ schema, auth }).functions(\n  ({ query, mutation }) => ({\n    todos: {\n      list: query({\n        description: \"List the signed-in user's todos.\",\n        args: {},\n        agent: { app: todoBoard },\n        handler: ({ db }) => db.table(\"todos\").collect(),\n      }),\n\n      add: mutation({\n        description: \"Create a todo and return the current list.\",\n        args: { title: s.string({ min: 1, max: 160 }) },\n        agent: { app: todoBoard, destructive: false },\n        handler: ({ db }, { title }) => {\n          db.table(\"todos\").insert({ title, done: false });\n          return db.table(\"todos\").collect();\n        },\n      }),\n\n      toggle: mutation({\n        description: \"Change completion state and return the current list.\",\n        args: {\n          id: s.id(\"todos\"),\n          version: s.number({ integer: true, min: 1 }),\n        },\n        agent: { app: todoBoard, destructive: false },\n        handler: ({ db }, { id, version }) => {\n          const todo = db.table(\"todos\").get(id);\n          if (todo) db.table(\"todos\").patch(id, { done: !todo.done }, { ifVersion: version });\n          return db.table(\"todos\").collect();\n        },\n      }),\n    },\n  }),\n);\n```\n\nClank collects and deduplicates the referenced view automatically. Deploying the backend publishes:\n\n```text\ntools/list\n  todos_list._meta.ui.resourceUri = ui://todos/board\n  todos_add._meta.ui.resourceUri = ui://todos/board\n  todos_toggle._meta.ui.resourceUri = ui://todos/board\n\nresources/list\n  ui://todos/board · text/html;profile=mcp-app\n\nresources/read { uri: \"ui://todos/board\" }\n  one HTML content item plus _meta.ui security policy\n```\n\nChanging the HTML, policy, resource metadata, binding, or visibility changes the deterministic MCP\ncontract revision. Hosts therefore do not retain a stale view while backend actions move forward.\n\nThe `resourceUri` link remains on every model-visible tool descriptor even when a capable host does\nnot advertise the MCP Apps extension on each stateless request. This compatibility behavior lets\nCodex and other strict stateless clients attach the view to the tool result automatically; clients\nthat do not understand MCP Apps safely ignore the unknown `_meta.ui` field. Capability negotiation\nstill controls whether app-only actions are included in `tools/list` or accepted by `tools/call`.\n\n## App-only actions\n\nAn embedded view sometimes needs an implementation action that should not enter the model's tool\nlist. Bind it with app-only visibility:\n\n```ts\nrefresh: query({\n  description: \"Refresh the interactive board.\",\n  args: {},\n  agent: {\n    app: {\n      resource: todoBoard,\n      visibility: [\"app\"],\n    },\n  },\n  handler: ({ db }) => db.table(\"todos\").collect(),\n}),\n```\n\nMCP Apps hosts can proxy this action for the view. Clients that did not negotiate the UI extension\ndo not see app-only tools. Ordinary tools default to both model and app visibility, and retain their\nresource link so hosts with incomplete stateless capability signaling can still render the view.\n\nVisibility controls discovery and host presentation; it is not an authorization boundary. Keep\nsensitive operations behind normal Clank authentication and `agent:read` or `agent:write` scope\nchecks, regardless of whether a tool is visible to the model, the app, or both.\n\n## Host context and actions\n\nThe inlined `globalThis.ClankMcpApp` object exposes the same runtime as the\n`@clank.run/framework/mcp-app` module:\n\n| Method | Purpose |\n| --- | --- |\n| `createMcpAppClient(options)` | Initialize the iframe connection and receive lifecycle events |\n| `client.callTool(name, args)` | Invoke a tool through the host's authenticated MCP connection |\n| `client.readResource(uri)` | Ask the host to read another server resource |\n| `client.openLink(url)` | Request a host-mediated HTTP(S) link open |\n| `client.downloadFile(contents)` | Request a host-mediated file download |\n| `client.sendMessage(content)` | Send user content to the host conversation |\n| `client.updateModelContext(value)` | Replace the view's deferred model context |\n| `client.requestDisplayMode(mode)` | Request `inline`, `fullscreen`, or `pip` |\n| `client.sendSizeChanged(size)` | Report responsive content dimensions |\n| `client.requestTeardown()` | Ask the host to remove the view |\n| `applyMcpAppTheme(context)` | Apply safe MCP host CSS variables and the light/dark marker |\n\nCallbacks cover complete and partial tool input, tool results, cancellation, host-context updates,\nand graceful teardown. The bridge accepts messages only from its configured parent window, uses\nbounded request timeouts, and rejects unsupported initialization versions.\n\n## Security policy\n\nDeclare the least authority the view needs:\n\n```ts\ndefineMcpApp({\n  // ...\n  csp: {\n    connectDomains: [\"https://api.example.com\"],\n    resourceDomains: [\"https://cdn.example.com\"],\n    frameDomains: [\"https://player.example.com\"],\n    baseUriDomains: [],\n  },\n  permissions: {\n    clipboardWrite: {},\n  },\n  prefersBorder: true,\n});\n```\n\nExternal origins must be secure, except explicit loopback origins used during development. Each\npermission uses the MCP Apps empty-object shape. Clank rejects unknown policy keys, duplicate or\npath-bearing origins, malformed `ui://` URIs, unsupported permission values, incomplete HTML\ndocuments, missing resources, and conflicting definitions with the same URI.\n\nThe host—not the view—owns OAuth credentials. Prefer `client.callTool()` over direct authenticated\nfetches. A view receives only the tool data and host capabilities required to render. Application\nauthorization, owned-row isolation, validation, optimistic concurrency, and write scopes still run\non every action.\n\n## Low-level MCP servers\n\nCustom servers can register the same resources directly:\n\n```ts\nconst server = createMcpServer({\n  name: \"reports\",\n  apps: [reportDashboard],\n  tools: [{\n    name: \"reports.summary\",\n    description: \"Summarize the current report.\",\n    inputSchema: { type: \"object\", additionalProperties: false },\n    app: {\n      resourceUri: reportDashboard.uri,\n      visibility: [\"model\", \"app\"],\n    },\n    invoke: () => ({ total: 42 }),\n  }],\n});\n```\n\nThe low-level property names intentionally mirror the wire contract. Normal Clank backends should\nprefer `agent: { app: view }` so resource collection and revision tracking remain automatic.\n\n## Verify with MCPJam\n\nAuthenticate first, save the credentials, then reuse them for protocol and MCP Apps checks:\n\n```sh\nnpx -y @mcpjam/cli@latest oauth conformance \\\n  --url https://my-app.apps.clank.run/__clank/mcp \\\n  --protocol-version 2025-11-25 \\\n  --registration dcr \\\n  --auth-mode interactive \\\n  --verify-tools \\\n  --credentials-out .mcpjam.json\n\nnpx -y @mcpjam/cli@latest apps conformance \\\n  --url https://my-app.apps.clank.run/__clank/mcp \\\n  --protocol-version 2026-07-28 \\\n  --credentials-file .mcpjam.json\n```\n\nDo not run the second command anonymously against a protected app: the correct OAuth `401` occurs\nbefore JSON-RPC and prevents the UI checks from discovering tools or resources.\n\nContinue with [The MCP server built into every app](per-app-mcp.md) for OAuth and action parity,\nand [Agent protocol](agent-protocol.md) for transport and revision details.\n"}