MCP Apps (Dynamic UIs)
MCP Apps is an extension to the Model Context Protocol that lets a tool return an interactive HTML UI instead of just text. The host (Claude, Claude Desktop, VS Code Copilot, and others) renders that UI inline in the conversation, inside a sandboxed iframe. The UI can call back into your server's tools and receive fresh data — a dashboard, form, chart, or viewer that lives right next to the chat.
In Keryx this is a natural extension of the action model. Just as an action already exposes an MCP tool, resource, or prompt, it can declare a UI. You point mcp.ui.client at a browser entrypoint and return a UIResponse from run() — Keryx bundles the client and wires up the rest.
Prerequisite
MCP Apps build on the MCP server. Enable it first (see MCP Server), then add a UI to any tool action.
How it works
An MCP App combines two MCP primitives that Keryx registers for you:
- A tool whose description points at a UI via
_meta.ui.resourceUri. - A
ui://resource that serves the app's self-contained HTML.
At startup Keryx bundles your mcp.ui.client entrypoint (with Bun's bundler) and inlines it into a self-contained HTML document. When the model calls the tool, the host fetches the ui:// resource, renders the HTML in a sandboxed iframe, and pushes the tool's structuredContent to the app for rendering. The app can then call any tool on your server to fetch more data — all over a secure postMessage channel.
Action (mcp.ui.client + returns UIResponse)
├─▶ tool "status-app" _meta.ui.resourceUri = "ui://status-app"
└─▶ resource "ui://status-app" → text/html;profile=mcp-app (bundled at boot)
tool call ─▶ UIResponse ─▶ { content: [text], structuredContent: {…} }
│ │
model context app renderingKeryx advertises the io.modelcontextprotocol/ui extension capability during initialize (when any action declares mcp.ui) so compliant hosts negotiate UI support.
Declaring a UI
An MCP App is two files: the action (server) and the client (browser).
1. The action
Add a ui block to an action's mcp config and point client at a browser entrypoint. Return a UIResponse from run().
import { Action, api, UIResponse } from "keryx";
import { z } from "zod";
export class StatusDashboardApp implements Action {
name = "status:app";
description = "Show live server status as an interactive dashboard.";
inputs = z.object({});
mcp = {
ui: {
client: new URL("../mcpApp/status.ts", import.meta.url), // Keryx bundles this
prefersBorder: true,
},
};
async run() {
return new UIResponse(
{
name: api.process.name,
pid: api.process.pid,
uptime: new Date().getTime() - api.bootTime,
},
{ text: `Server ${api.process.name} is running.` },
);
}
}That's it — the tool status-app is now linked to a ui://status-app resource whose HTML embeds your bundled client, and calling it delivers the structured data to the app.
No build step, no CDN
client is bundled at boot into one self-contained HTML document, so the sandboxed iframe needs no network access and the default deny-by-default CSP just works. You don't run a bundler, add a build script, or maintain a placeholder HTML file. (Bundling happens in a short-lived child process during startup.)
2. The client
Install the browser client package:
bun add @keryxjs/mcp-appWrite your entrypoint against mountMcpApp. It connects to the host, renders the tool's structured data, and keeps it fresh:
// mcpApp/status.ts
import type { ActionResponse } from "keryx";
import { mountMcpApp } from "@keryxjs/mcp-app";
import type { Status } from "../actions/status";
// Reuse the action's response type instead of redefining it — the UI binds to
// exactly what the tool returns. See Typed Clients.
type StatusData = ActionResponse<Status>;
mountMcpApp<StatusData>({
name: "Server Status",
render: (data, root) => {
root.innerHTML = `<h1>${data.name}</h1><p>PID ${data.pid}</p>`;
},
refreshTool: { name: "status" }, // optional: powers refresh() + self-hydrate
});root defaults to the #root element in the shell Keryx generates, so a minimal app needs no HTML of its own. When you want custom markup or CSS, provide your own shell — see Custom HTML shell.
Type the UI off your action
import type a server action and use ActionResponse<A> for the render payload — the import type is erased at bundle time, so no server code reaches the browser, and your UI stays in sync with the tool's return type. This is the same zero-tooling pattern the frontend uses.
TypeScript
Browser code needs DOM types, which must not leak into your Bun server program (they clash with Bun's WebSocket). Keep browser files in their own directory with a 2-line tsconfig that extends the base shipped by @keryxjs/mcp-app:
// mcpApp/tsconfig.json
{ "extends": "@keryxjs/mcp-app/tsconfig.mcp-app.json", "include": ["**/*.ts"] }Then exclude that directory from your server tsconfig ("exclude": ["mcpApp"]) and typecheck it separately (tsc -p mcpApp).
mountMcpApp
mountMcpApp(options) wraps the @modelcontextprotocol/ext-apps App and the connect/hydrate lifecycle. It registers the tool-result handler before connecting (so the host's initial data push is never missed) and self-hydrates by calling the refresh tool if that push never arrives — the workaround some hosts (e.g. Cursor) require.
const { app, refresh } = await mountMcpApp<Status>({
name: "Server Status", // reported to the host (default "MCP App")
version: "1.0.0",
render: (data, root) => { /* paint the DOM */ },
onError: (err) => { /* surface failures (default: swallowed) */ },
root: "#root", // element or selector passed to render (default "#root")
refreshTool: { name: "status" }, // tool for refresh()/self-hydrate
});It returns { app, refresh }: call refresh() (e.g. from a button) to re-fetch and re-render; app is the underlying App for advanced use. Because every Keryx action is already a tool, your app can call any of them — JSON object tool results include structuredContent automatically, so apps bind without JSON.parse. If you omit refreshTool, mountMcpApp falls back to the app's own tool when the host advertises it.
UIResponse
Return a UIResponse from run() to hand the host two payloads at once:
structuredContent— the object your app UI renders. Delivered to the app, not added to the model's context.text— a text summary added to the model's context. Defaults toJSON.stringify(structuredContent).
new UIResponse(structuredContent, { text: "optional model-facing summary" });
// or: UIResponse.from(structuredContent, { text: "…" })Over non-MCP transports (HTTP, WebSocket, CLI) a UIResponse serializes to its structuredContent via toJSON(), so the same action still returns useful JSON everywhere. A GET to the action's web route returns the structured object directly.
UIResponse is generic over the shape of structuredContent. When you build one from an object literal, the field types are inferred and flow through to run()'s return type — and from there into the generated OpenAPI/MCP response schema, so the app UI has a fully-typed payload to bind against rather than an opaque object.
McpUiConfig options
| Property | Type | Default | Description |
|---|---|---|---|
client | string | URL | — | Browser entrypoint Keryx bundles at boot. Provide client, html, or both. |
html | string | (default shell) | Verbatim HTML, or (with client) the shell the bundle is inlined into. |
resourceUri | string | ui://<tool-name> | The ui:// resource URI the tool links to. |
csp | object | — | External origins the app may reach (see CSP). |
permissions | object | — | Extra iframe capabilities: camera, microphone, geolocation, clipboardWrite. |
prefersBorder | boolean | — | Hint the host to render a border/frame around the app. |
domain | string | — | Logical grouping/isolation hint for the host. |
At least one of client or html must be set.
Custom HTML shell
For custom markup or CSS, pass an HTML shell as html alongside client. Keryx inlines the bundled client into an empty <script type="module"></script> (or a /* MCP_APP_CLIENT */ placeholder comment, else before </body>):
mcp = {
ui: {
client: new URL("../mcpApp/status.ts", import.meta.url),
html: await Bun.file(new URL("./status-app.html", import.meta.url)).text(),
},
};Your render function can then target elements in that shell directly instead of the default #root.
Shared theme
App shells render in a sandboxed iframe, so they cannot <link> to a stylesheet on your origin — shared branding must be inlined source. Set config.server.web.theme to a .css file (or a .ts/.js entrypoint that default-exports a CSS string) and Keryx inlines that CSS into every shell at boot — the same theme it applies to the OAuth authorization page, so both Keryx-rendered surfaces stay consistent.
The default shell already inlines Keryx's shared --keryx-* design tokens, so your render code can reference the palette (color: var(--keryx-color-primary)) and inherits the shared font by default — even before you configure a theme. Your configured theme is inlined after the tokens, so it overrides them.
The default shell picks the theme up automatically. In a custom html shell, mark where the theme should land with a /* MCP_APP_THEME */ comment (mirroring /* MCP_APP_CLIENT */); if you omit it, Keryx inserts a <style> block into <head>. When no theme is configured, custom shells are served byte-for-byte verbatim.
<style>
:root { color-scheme: light dark; }
/* MCP_APP_THEME */
</style>Bring your own HTML (escape hatch)
You don't have to use client at all. Set only html to a fully self-contained string and Keryx serves it verbatim — you inline your own scripts and styles (e.g. a UI you bundle with Vite and vite-plugin-singlefile):
mcp = { ui: { html: await Bun.file(builtHtmlPath).text() } };CSP and permissions
Apps render under a deny-by-default Content-Security-Policy. Keep the HTML self-contained (the client path does this for you) and no CSP tuning is needed. If your UI loads scripts, styles, or data from external origins, declare them:
mcp = {
ui: {
client: new URL("../mcpApp/status.ts", import.meta.url),
csp: {
resourceDomains: ["https://esm.sh"], // load scripts/styles from
connectDomains: ["https://api.example.com"], // fetch/XHR/WebSocket to
},
permissions: { clipboardWrite: {} },
},
};| CSP field | Controls |
|---|---|
resourceDomains | Scripts, styles, images, fonts the app may load |
connectDomains | Origins the app may fetch/XHR/WebSocket to |
frameDomains | Origins the app may embed in nested frames |
baseUriDomains | Origins allowed in the app's <base href> |
Testing
To see an app render you need a host that supports MCP Apps. Two easy options:
basic-host — the ext-apps repo ships a local test host. From
examples/basic-host, point it at your server:bashSERVERS='["http://localhost:8080/mcp"]' npm startClaude — expose your local server with a tunnel (e.g.
npx cloudflared tunnel --url http://localhost:8080) and add it as a custom connector.
For automated tests, assert the wiring over a normal MCP session: the tool's _meta.ui.resourceUri appears in tools/list, the ui:// resource reads back HTML with the text/html;profile=mcp-app MIME type (and contains your bundled client), and a tools/call returns structuredContent. See example/backend/__tests__/initializers/mcp.test.ts.
Rendering is up to the host
Whether an app actually renders is controlled entirely by the host. MCP Apps is new, and support varies — some clients gate it behind a server-side flag or have version-specific regressions (e.g. a host may negotiate the io.modelcontextprotocol/ui capability and list the ui:// resource, yet never call resources/read, falling back to raw text). If a compliant host like the ext-apps basic-host renders your app but another client doesn't, the gap is on the client side, not in your server.
See also
- MCP Server — enabling MCP, tools, resources, prompts, OAuth
@keryxjs/mcp-app— the browser client (mountMcpApp)- MCP Apps specification
- ext-apps examples — starter templates for React, Vue, Svelte, and vanilla JS