diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts index bc3fbff14..2ab7c2bf0 100644 --- a/examples/basic-host/serve.ts +++ b/examples/basic-host/serve.ts @@ -124,7 +124,10 @@ sandboxApp.get(["/", "/sandbox.html"], (req, res) => { res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); - res.sendFile(join(DIRECTORY, "sandbox.html")); + // Pass the file relative to `root` — with an absolute path, express applies + // its dotfiles="ignore" rule to every segment and 404s when the repo checkout + // lives under a dot-directory (e.g. git worktrees in .claude/worktrees). + res.sendFile("sandbox.html", { root: DIRECTORY }); }); sandboxApp.use((_req, res) => { diff --git a/examples/dynamic-content-server/.gitignore b/examples/dynamic-content-server/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/examples/dynamic-content-server/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/examples/dynamic-content-server/README.md b/examples/dynamic-content-server/README.md new file mode 100644 index 000000000..95d2fd3a7 --- /dev/null +++ b/examples/dynamic-content-server/README.md @@ -0,0 +1,66 @@ +# Example: Dynamic View Content + +An MCP App example demonstrating **Dynamic View Content**: a generic, predeclared renderer view driven by typed payloads that tools return as embedded resources marked with `_meta.ui.content`. + +Instead of baking presentation into the template and passing data via `structuredContent`, the server generates a declarative UI document at tool-call time. The host forwards the payload (unmodified, and excluded from model context) to the renderer view, which interprets it. Button clicks in the rendered surface are bridged back into `tools/call` requests to an app-visibility tool, whose responses carry new payloads — closing the interactive loop. + +This is the pattern used by generative UI formats such as [A2UI](https://a2ui.org) (`application/a2ui+json`). This example uses a deliberately tiny stand-in format (`application/vnd.example.dynamic-ui+json`, defined in [`dynamic-ui.ts`](dynamic-ui.ts)) so the plumbing stays easy to follow. + +## MCP Client Configuration + +Add to your MCP client configuration (stdio transport): + +```json +{ + "mcpServers": { + "dynamic-content": { + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-dynamic-content", + "--stdio" + ] + } + } +} +``` + +### Local Development + +To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): + +```json +{ + "mcpServers": { + "dynamic-content": { + "command": "bash", + "args": [ + "-c", + "cd ~/code/ext-apps/examples/dynamic-content-server && npm run build >&2 && node dist/index.js --stdio" + ] + } + } +} +``` + +## Overview + +- A renderer resource declaring the payload types it renders via `contentMimeTypes` in its `_meta.ui` +- A model-visible tool (`search-flights`) returning a text fallback for model context plus a marked payload created with [`createViewContentBlock`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.createViewContentBlock.html) +- An app-visibility tool (`select-flight`, hidden from the model) that the renderer's event bridge calls; its response carries the next payload +- A generic renderer extracting payloads with [`getViewContentBlocks`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.getViewContentBlocks.html) and building DOM with `createElement`/`textContent` only — payloads are untrusted input + +## Key Files + +- [`dynamic-ui.ts`](dynamic-ui.ts) - The example payload format (MIME type + component types) +- [`server.ts`](server.ts) - Renderer resource + tools returning marked payloads +- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - The generic renderer view and its event bridge + +## Getting Started + +```bash +npm install +npm run dev +``` diff --git a/examples/dynamic-content-server/dynamic-ui.ts b/examples/dynamic-content-server/dynamic-ui.ts new file mode 100644 index 000000000..194405b90 --- /dev/null +++ b/examples/dynamic-content-server/dynamic-ui.ts @@ -0,0 +1,47 @@ +/** + * @file Shared definition of this example's dynamic content payload format. + * + * This is a deliberately tiny declarative UI language, standing in for real + * generative UI formats such as A2UI (`application/a2ui+json`). The server + * produces documents in this format at tool-call time; the generic renderer + * view interprets them. The MCP Apps plumbing is identical for any format: + * the payload rides in tool results as an embedded resource marked with + * `_meta.ui.content`, typed by its MIME type. + */ + +/** MIME type of this example's dynamic content payloads. */ +export const DYNAMIC_UI_MIME_TYPE = "application/vnd.example.dynamic-ui+json"; + +/** A node in the declarative component tree. */ +export type UiComponent = + | { + kind: "text"; + text: string; + variant?: "title" | "body" | "note"; + } + | { + kind: "row" | "column"; + children: UiComponent[]; + } + | { + kind: "card"; + children: UiComponent[]; + } + | { + kind: "button"; + label: string; + /** + * The event bridge: the renderer translates a click into a `tools/call` + * request for this (typically app-visibility) tool. The response carries + * new marked payloads, which the renderer applies. + */ + action: { + tool: string; + arguments: Record; + }; + }; + +/** A dynamic content payload document: a surface to render. */ +export type UiSurface = { + surface: UiComponent[]; +}; diff --git a/examples/dynamic-content-server/main.ts b/examples/dynamic-content-server/main.ts new file mode 100644 index 000000000..e7c040bdb --- /dev/null +++ b/examples/dynamic-content-server/main.ts @@ -0,0 +1,93 @@ +/** + * Entry point for running the MCP server. + * Run with: npx mcp-server-dynamic-content + * Or: node dist/index.js [--stdio] + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import cors from "cors"; +import type { Request, Response } from "express"; +import { createServer } from "./server.js"; + +/** + * Starts an MCP server with Streamable HTTP transport in stateless mode. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + */ +export async function startStreamableHTTPServer( + createServer: () => McpServer, +): Promise { + const port = parseInt(process.env.PORT ?? "3001", 10); + + const app = createMcpExpressApp({ host: "0.0.0.0" }); + app.use(cors()); + + app.all("/mcp", async (req: Request, res: Response) => { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("MCP error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + const httpServer = app.listen(port, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } + console.log(`MCP server listening on http://localhost:${port}/mcp`); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +/** + * Starts an MCP server with stdio transport. + * + * @param createServer - Factory function that creates a new McpServer instance. + */ +export async function startStdioServer( + createServer: () => McpServer, +): Promise { + await createServer().connect(new StdioServerTransport()); +} + +async function main() { + if (process.argv.includes("--stdio")) { + await startStdioServer(createServer); + } else { + await startStreamableHTTPServer(createServer); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/dynamic-content-server/mcp-app.html b/examples/dynamic-content-server/mcp-app.html new file mode 100644 index 000000000..7c446a407 --- /dev/null +++ b/examples/dynamic-content-server/mcp-app.html @@ -0,0 +1,17 @@ + + + + + + + Dynamic Content Renderer + + +
+
+

Waiting for content…

+
+
+ + + diff --git a/examples/dynamic-content-server/package.json b/examples/dynamic-content-server/package.json new file mode 100644 index 000000000..c96ac23b9 --- /dev/null +++ b/examples/dynamic-content-server/package.json @@ -0,0 +1,53 @@ +{ + "name": "@modelcontextprotocol/server-dynamic-content", + "version": "1.7.4", + "type": "module", + "description": "MCP App Server example demonstrating Dynamic View Content: a generic renderer view driven by typed payloads returned from tool calls as marked embedded resources", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/dynamic-content-server" + }, + "license": "MIT", + "main": "dist/server.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "bun --watch main.ts", + "serve:stdio": "bun main.ts --stdio", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", + "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.7.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "22.10.0", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + }, + "types": "dist/server.d.ts", + "exports": { + ".": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "bin": { + "mcp-server-dynamic-content": "dist/index.js" + } +} diff --git a/examples/dynamic-content-server/server.ts b/examples/dynamic-content-server/server.ts new file mode 100644 index 000000000..e76141c5c --- /dev/null +++ b/examples/dynamic-content-server/server.ts @@ -0,0 +1,268 @@ +/** + * @file MCP server demonstrating Dynamic View Content. + * + * The predeclared `ui://` resource is a generic renderer: it declares (via + * `contentMimeTypes`) that it renders `application/vnd.example.dynamic-ui+json` + * payloads. Tools don't bake data into the template — they return declarative + * UI documents as embedded resources marked with `_meta.ui.content`, which the + * host forwards to the view. Button clicks in the view come back as + * `tools/call` requests to an app-visibility tool, whose response carries new + * payloads — closing the interactive loop. + */ +import { + createViewContentBlock, + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { DYNAMIC_UI_MIME_TYPE, type UiSurface } from "./dynamic-ui.js"; + +// Works both from source (server.ts) and compiled (dist/server.js) +const DIST_DIR = import.meta.filename.endsWith(".ts") + ? path.join(import.meta.dirname, "dist") + : import.meta.dirname; + +const RESOURCE_URI = "ui://dynamic-content-server/renderer.html"; + +type Flight = { + id: string; + airline: string; + departure: string; + arrival: string; + price: number; +}; + +const FLIGHTS: Flight[] = [ + { + id: "UA954", + airline: "United", + departure: "08:10", + arrival: "12:35", + price: 1240, + }, + { + id: "LY007", + airline: "El Al", + departure: "10:45", + arrival: "15:20", + price: 1180, + }, + { + id: "DL221", + airline: "Delta", + departure: "16:30", + arrival: "21:05", + price: 990, + }, +]; + +/** Build the flight results surface — the server generates UI at call time. */ +function flightResultsSurface(destination: string): UiSurface { + return { + surface: [ + { kind: "text", variant: "title", text: `Flights to ${destination}` }, + { + kind: "column", + children: FLIGHTS.map((flight) => ({ + kind: "card" as const, + children: [ + { + kind: "row" as const, + children: [ + { + kind: "text" as const, + text: `${flight.airline} ${flight.id}`, + }, + { + kind: "text" as const, + variant: "note" as const, + text: `${flight.departure} → ${flight.arrival}`, + }, + { kind: "text" as const, text: `$${flight.price}` }, + { + kind: "button" as const, + label: "Select", + // The renderer's event bridge turns this into a tools/call + action: { + tool: "select-flight", + arguments: { flightId: flight.id }, + }, + }, + ], + }, + ], + })), + }, + { kind: "text", variant: "note", text: "Prices include taxes and fees." }, + ], + }; +} + +/** Build the confirmation surface returned by the app-visibility tool. */ +function confirmationSurface(flight: Flight): UiSurface { + return { + surface: [ + { kind: "text", variant: "title", text: "Flight selected" }, + { + kind: "card", + children: [ + { + kind: "text", + text: `${flight.airline} ${flight.id}, departs ${flight.departure}`, + }, + { kind: "text", text: `Total: $${flight.price}` }, + { + kind: "text", + variant: "note", + text: "This is a demo — nothing was booked.", + }, + ], + }, + { + kind: "button", + label: "Back to results", + action: { + tool: "search-flights", + arguments: { destination: "San Francisco" }, + }, + }, + ], + }; +} + +/** + * Creates a new MCP server instance with tools and resources registered. + */ +export function createServer(): McpServer { + const server = new McpServer({ + name: "Dynamic View Content Example Server", + version: "1.0.0", + }); + + // Model-visible tool: returns a text fallback for model context plus a + // marked dynamic content payload for the renderer view. + // + // Production servers should gate this registration on the host's negotiated + // `contentMimeTypes` extension setting — see `getUiCapability` and + // `supportsContentMimeType` — and degrade to a text-only variant otherwise. + registerAppTool( + server, + "search-flights", + { + title: "Search Flights", + description: + "Search for flights to a destination and present the options.", + inputSchema: { + destination: z + .string() + .describe("Destination city") + .default("San Francisco"), + }, + _meta: { ui: { resourceUri: RESOURCE_URI } }, + }, + async ({ destination }): Promise => { + const cheapest = FLIGHTS.reduce((a, b) => (a.price <= b.price ? a : b)); + return { + content: [ + // Text fallback: model context and text-only hosts + { + type: "text", + text: `Found ${FLIGHTS.length} flights to ${destination}. Cheapest: ${cheapest.airline} ${cheapest.id} at $${cheapest.price}.`, + }, + // Dynamic content payload: forwarded to the renderer view, + // excluded from model context + createViewContentBlock({ + uri: `dynamic-ui://dynamic-content-server/surfaces/${encodeURIComponent(destination)}`, + mimeType: DYNAMIC_UI_MIME_TYPE, + text: JSON.stringify(flightResultsSurface(destination)), + }), + ], + }; + }, + ); + + // App-visibility tool: hidden from the model, called by the renderer's + // event bridge. Its response carries the next payload in the loop. + registerAppTool( + server, + "select-flight", + { + title: "Select Flight", + description: "Select a flight from previously presented options.", + inputSchema: { + flightId: z.string().describe("Flight id from the presented options"), + }, + _meta: { ui: { resourceUri: RESOURCE_URI, visibility: ["app"] } }, + }, + async ({ flightId }): Promise => { + const flight = FLIGHTS.find((f) => f.id === flightId); + if (!flight) { + return { + content: [{ type: "text", text: `Unknown flight: ${flightId}` }], + isError: true, + }; + } + return { + content: [ + { + type: "text", + text: `Selected ${flight.airline} ${flight.id} ($${flight.price}).`, + }, + createViewContentBlock({ + uri: `dynamic-ui://dynamic-content-server/surfaces/confirmation-${flight.id}`, + mimeType: DYNAMIC_UI_MIME_TYPE, + text: JSON.stringify(confirmationSurface(flight)), + }), + ], + }; + }, + ); + + // The renderer resource: predeclared, prefetchable, reviewable. It declares + // the payload MIME types it renders via `contentMimeTypes`. + registerAppResource( + server, + RESOURCE_URI, + RESOURCE_URI, + { + mimeType: RESOURCE_MIME_TYPE, + _meta: { + ui: { + contentMimeTypes: [DYNAMIC_UI_MIME_TYPE], + prefersBorder: true, + }, + }, + }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + contentMimeTypes: [DYNAMIC_UI_MIME_TYPE], + prefersBorder: true, + }, + }, + }, + ], + }; + }, + ); + + return server; +} diff --git a/examples/dynamic-content-server/src/global.css b/examples/dynamic-content-server/src/global.css new file mode 100644 index 000000000..801291f46 --- /dev/null +++ b/examples/dynamic-content-server/src/global.css @@ -0,0 +1,92 @@ +:root { + color-scheme: light dark; + + /* + * Fallbacks for host style variables used by this app. + * The host may provide these (and many more) via the host context. + */ + --color-text-primary: light-dark(#1f2937, #f3f4f6); + --color-text-inverse: light-dark(#f3f4f6, #1f2937); + --color-text-info: light-dark(#1d4ed8, #60a5fa); + --color-background-primary: light-dark(#ffffff, #1a1a1a); + --color-background-inverse: light-dark(#1a1a1a, #ffffff); + --color-background-info: light-dark(#eff6ff, #1e3a5f); + --color-ring-primary: light-dark(#3b82f6, #60a5fa); + --border-radius-md: 6px; + --border-width-regular: 1px; + --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --font-weight-normal: 400; + --font-weight-bold: 700; + --font-text-md-size: 1rem; + --font-text-md-line-height: 1.5; + --font-heading-3xl-size: 2.25rem; + --font-heading-3xl-line-height: 1.1; + --font-heading-2xl-size: 1.875rem; + --font-heading-2xl-line-height: 1.2; + --font-heading-xl-size: 1.5rem; + --font-heading-xl-line-height: 1.25; + --font-heading-lg-size: 1.25rem; + --font-heading-lg-line-height: 1.3; + --font-heading-md-size: 1rem; + --font-heading-md-line-height: 1.4; + --font-heading-sm-size: 0.875rem; + --font-heading-sm-line-height: 1.4; + + /* Spacing derived from host typography */ + --spacing-unit: var(--font-text-md-size); + --spacing-xs: calc(var(--spacing-unit) * 0.25); + --spacing-sm: calc(var(--spacing-unit) * 0.5); + --spacing-md: var(--spacing-unit); + --spacing-lg: calc(var(--spacing-unit) * 1.5); + + /* App accent color (customize for your brand) */ + --color-accent: #2563eb; + --color-text-on-accent: #ffffff; +} + +* { + box-sizing: border-box; +} + +html, body { + font-family: var(--font-sans); + font-size: var(--font-text-md-size); + font-weight: var(--font-weight-normal); + line-height: var(--font-text-md-line-height); + color: var(--color-text-primary); +} + +h1 { + font-size: var(--font-heading-3xl-size); + line-height: var(--font-heading-3xl-line-height); +} +h2 { + font-size: var(--font-heading-2xl-size); + line-height: var(--font-heading-2xl-line-height); +} +h3 { + font-size: var(--font-heading-xl-size); + line-height: var(--font-heading-xl-line-height); +} +h4 { + font-size: var(--font-heading-lg-size); + line-height: var(--font-heading-lg-line-height); +} +h5 { + font-size: var(--font-heading-md-size); + line-height: var(--font-heading-md-line-height); +} +h6 { + font-size: var(--font-heading-sm-size); + line-height: var(--font-heading-sm-line-height); +} + +code, pre, kbd { + font-family: var(--font-mono); + font-size: 1em; +} + +b, strong { + font-weight: var(--font-weight-bold); +} diff --git a/examples/dynamic-content-server/src/mcp-app.css b/examples/dynamic-content-server/src/mcp-app.css new file mode 100644 index 000000000..80453f190 --- /dev/null +++ b/examples/dynamic-content-server/src/mcp-app.css @@ -0,0 +1,81 @@ +body { + margin: 0; + background: var(--color-background-primary); +} + +.main { + padding: var(--spacing-md); +} + +.surface { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.surface.busy { + opacity: 0.6; + pointer-events: none; +} + +.status { + color: var(--color-text-info); + margin: 0; +} + +.text { + margin: 0; +} + +.text-title { + margin: 0 0 var(--spacing-xs); +} + +.text-note { + font-size: 0.875em; + opacity: 0.7; +} + +.row { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +.row > .text { + flex: 1 1 auto; +} + +.column { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.card { + border: var(--border-width-regular) solid + color-mix(in srgb, var(--color-text-primary) 15%, transparent); + border-radius: var(--border-radius-md); + padding: var(--spacing-sm) var(--spacing-md); +} + +button { + font: inherit; + padding: var(--spacing-xs) var(--spacing-md); + border: none; + border-radius: var(--border-radius-md); + background: var(--color-accent); + color: var(--color-text-on-accent); + cursor: pointer; +} + +button:hover { + filter: brightness(1.1); +} + +button:focus-visible { + outline: 2px solid var(--color-ring-primary); + outline-offset: 1px; +} diff --git a/examples/dynamic-content-server/src/mcp-app.ts b/examples/dynamic-content-server/src/mcp-app.ts new file mode 100644 index 000000000..e1e40b898 --- /dev/null +++ b/examples/dynamic-content-server/src/mcp-app.ts @@ -0,0 +1,162 @@ +/** + * @file A generic dynamic content renderer. + * + * This view contains no flight-specific presentation logic. It interprets + * declarative payload documents (see ../dynamic-ui.ts) extracted from tool + * results with `getViewContentBlocks`, and bridges payload-level button + * actions back into `tools/call` requests. The same pattern applies to real + * generative UI formats such as A2UI. + * + * Security note: payloads are untrusted input. The renderer builds DOM with + * `createElement`/`textContent` only — never `innerHTML` of payload-derived + * strings. + */ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + getViewContentBlocks, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + DYNAMIC_UI_MIME_TYPE, + type UiComponent, + type UiSurface, +} from "../dynamic-ui"; +import "./global.css"; +import "./mcp-app.css"; + +const mainEl = document.querySelector(".main") as HTMLElement; +const surfaceEl = document.getElementById("surface")!; + +function handleHostContextChanged(ctx: McpUiHostContext) { + if (ctx.theme) { + applyDocumentTheme(ctx.theme); + } + if (ctx.styles?.variables) { + applyHostStyleVariables(ctx.styles.variables); + } + if (ctx.styles?.css?.fonts) { + applyHostFonts(ctx.styles.css.fonts); + } + if (ctx.safeAreaInsets) { + mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; + mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; + mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; + mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; + } +} + +const app = new App({ name: "Dynamic Content Renderer", version: "1.0.0" }); + +function setStatus(text: string) { + surfaceEl.replaceChildren(); + const status = document.createElement("p"); + status.className = "status"; + status.textContent = text; + surfaceEl.append(status); +} + +/** The event bridge: payload-level actions become MCP tool calls. */ +async function invokeAction(action: { + tool: string; + arguments: Record; +}) { + surfaceEl.classList.add("busy"); + try { + const result = await app.callServerTool({ + name: action.tool, + arguments: action.arguments, + }); + applyToolResult(result); + } catch (e) { + console.error("Action failed:", e); + setStatus("Something went wrong — check the console."); + } finally { + surfaceEl.classList.remove("busy"); + } +} + +function renderComponent(component: UiComponent): HTMLElement { + switch (component.kind) { + case "text": { + const variant = component.variant ?? "body"; + const el = document.createElement(variant === "title" ? "h3" : "p"); + el.className = `text text-${variant}`; + el.textContent = component.text; + return el; + } + case "row": + case "column": + case "card": { + const el = document.createElement("div"); + el.className = component.kind; + el.append(...component.children.map(renderComponent)); + return el; + } + case "button": { + const el = document.createElement("button"); + el.textContent = component.label; + el.addEventListener("click", () => invokeAction(component.action)); + return el; + } + default: { + // Unknown component kinds are skipped, not errors: payload formats + // evolve independently of the renderer. + const el = document.createElement("span"); + el.hidden = true; + return el; + } + } +} + +function renderSurface(document_: UiSurface) { + surfaceEl.replaceChildren(...document_.surface.map(renderComponent)); +} + +/** Extract marked payloads from any tool result and render them in order. */ +function applyToolResult(result: CallToolResult) { + const payloads = getViewContentBlocks(result, { + mimeType: DYNAMIC_UI_MIME_TYPE, + }); + if (payloads.length === 0) { + console.info("Tool result carried no dynamic content payloads:", result); + return; + } + for (const block of payloads) { + if ("text" in block.resource) { + try { + renderSurface(JSON.parse(block.resource.text) as UiSurface); + } catch (e) { + console.error("Invalid payload from", block.resource.uri, e); + } + } + } +} + +app.ontoolinput = (params) => { + console.info("Received tool call input:", params); + setStatus("Searching…"); +}; + +app.ontoolresult = (result) => { + console.info("Received tool call result:", result); + applyToolResult(result); +}; + +app.ontoolcancelled = (params) => { + console.info("Tool call cancelled:", params.reason); + setStatus("Cancelled."); +}; + +app.onerror = console.error; +app.onhostcontextchanged = handleHostContextChanged; + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx) { + handleHostContextChanged(ctx); + } +}); diff --git a/examples/dynamic-content-server/tsconfig.json b/examples/dynamic-content-server/tsconfig.json new file mode 100644 index 000000000..685c9a73f --- /dev/null +++ b/examples/dynamic-content-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts", "dynamic-ui.ts"] +} diff --git a/examples/dynamic-content-server/tsconfig.server.json b/examples/dynamic-content-server/tsconfig.server.json new file mode 100644 index 000000000..6201840fb --- /dev/null +++ b/examples/dynamic-content-server/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["server.ts", "dynamic-ui.ts"] +} diff --git a/examples/dynamic-content-server/vite.config.ts b/examples/dynamic-content-server/vite.config.ts new file mode 100644 index 000000000..6ff6d9979 --- /dev/null +++ b/examples/dynamic-content-server/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/package-lock.json b/package-lock.json index 29e905875..1a20022cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -546,6 +546,48 @@ "dev": true, "license": "MIT" }, + "examples/dynamic-content-server": { + "name": "@modelcontextprotocol/server-dynamic-content", + "version": "1.7.4", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.7.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "zod": "^4.1.13" + }, + "bin": { + "mcp-server-dynamic-content": "dist/index.js" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "22.10.0", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, + "examples/dynamic-content-server/node_modules/@types/node": { + "version": "22.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", + "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "examples/dynamic-content-server/node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "examples/integration-server": { "version": "1.7.4", "dependencies": { @@ -2693,6 +2735,10 @@ "resolved": "examples/debug-server", "link": true }, + "node_modules/@modelcontextprotocol/server-dynamic-content": { + "resolved": "examples/dynamic-content-server", + "link": true + }, "node_modules/@modelcontextprotocol/server-lazy-auth": { "resolved": "examples/lazy-auth-server", "link": true diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 23383c40f..cef4f4d26 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -94,6 +94,7 @@ import { McpUiToolMeta, } from "./types"; export * from "./types"; +export * from "./ui-content"; export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app"; import { RESOURCE_URI_META_KEY } from "./app"; export { PostMessageTransport } from "./message-transport"; diff --git a/src/app.ts b/src/app.ts index adfad5c77..a3e9466db 100644 --- a/src/app.ts +++ b/src/app.ts @@ -83,6 +83,7 @@ export type { export { PostMessageTransport } from "./message-transport"; export * from "./types"; +export * from "./ui-content"; export { applyHostStyleVariables, applyHostFonts, diff --git a/src/generated/schema.json b/src/generated/schema.json index 80b4ac60d..6e4e71681 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -59,6 +59,23 @@ "items": { "type": "string" } + }, + "contentMimeTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "McpUiContentBlockMeta": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "rendererUri": { + "description": "URI of the `ui://` renderer resource this payload targets.\n\nIf omitted, the payload targets the calling tool's `_meta.ui.resourceUri`.\nExplicit targeting supports future multi-view tool results.", + "type": "string" } }, "additionalProperties": false @@ -4235,6 +4252,12 @@ "prefersBorder": { "description": "Visual boundary preference - true if view prefers a visible border.\n\nBoolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary.\n\n- `true`: request visible border + background\n- `false`: request no visible border + background\n- omitted: host decides border", "type": "boolean" + }, + "contentMimeTypes": { + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 57d989fd0..0dcc3f035 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -127,6 +127,10 @@ export type McpUiToolMetaSchemaInferredType = z.infer< typeof generated.McpUiToolMetaSchema >; +export type McpUiContentBlockMetaSchemaInferredType = z.infer< + typeof generated.McpUiContentBlockMetaSchema +>; + export type McpUiClientCapabilitiesSchemaInferredType = z.infer< typeof generated.McpUiClientCapabilitiesSchema >; @@ -305,6 +309,12 @@ expectType( ); expectType({} as McpUiToolMetaSchemaInferredType); expectType({} as spec.McpUiToolMeta); +expectType( + {} as McpUiContentBlockMetaSchemaInferredType, +); +expectType( + {} as spec.McpUiContentBlockMeta, +); expectType( {} as McpUiClientCapabilitiesSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 43687374e..71a9b877a 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -669,6 +669,21 @@ export const McpUiResourceMetaSchema = z.object({ .describe( "Visual boundary preference - true if view prefers a visible border.\n\nBoolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary.\n\n- `true`: request visible border + background\n- `false`: request no visible border + background\n- omitted: host decides border", ), + /** + * @description MIME types of dynamic content payloads this view renders. + * + * When present, the view acts as a renderer for typed payloads returned by + * its associated tools as embedded resources marked with `_meta.ui.content` + * (see {@link McpUiContentBlockMeta `McpUiContentBlockMeta`}). Does not + * affect the resource's own `mimeType`, which remains + * `"text/html;profile=mcp-app"`. + * + * @example + * ```ts + * ["application/a2ui+json"] + * ``` + */ + contentMimeTypes: z.array(z.string()).optional(), }); /** @@ -742,6 +757,32 @@ export const McpUiToolMetaSchema = z.object({ permissions: z.never().optional(), }); +/** + * @description Metadata marking an embedded resource content block in a tool + * result as a dynamic view content payload. + * + * Placed at `_meta.ui.content` on `type: "resource"` content blocks within + * `CallToolResult.content`. Marked payloads are presentation data for the + * tool's view: hosts forward them unmodified to the view (via + * `ui/notifications/tool-result` and proxied `tools/call` responses) and + * exclude them from model context. The payload's `mimeType` must be declared + * in the target view's {@link McpUiResourceMeta.contentMimeTypes `contentMimeTypes`}. + */ +export const McpUiContentBlockMetaSchema = z.object({ + /** + * @description URI of the `ui://` renderer resource this payload targets. + * + * If omitted, the payload targets the calling tool's `_meta.ui.resourceUri`. + * Explicit targeting supports future multi-view tool results. + */ + rendererUri: z + .string() + .optional() + .describe( + "URI of the `ui://` renderer resource this payload targets.\n\nIf omitted, the payload targets the calling tool's `_meta.ui.resourceUri`.\nExplicit targeting supports future multi-view tool results.", + ), +}); + /** * @description MCP Apps capability settings advertised by clients to servers. * @@ -760,6 +801,17 @@ export const McpUiClientCapabilitiesSchema = z.object({ .describe( 'Array of supported MIME types for UI resources.\nMust include `"text/html;profile=mcp-app"` for MCP Apps support.', ), + /** + * @description Dynamic content payload MIME types the host will forward to + * views (see {@link McpUiContentBlockMeta `McpUiContentBlockMeta`}). + * + * Hosts may advertise `["*"]` to indicate they forward any payload type + * declared by a view's `contentMimeTypes` — hosts never need to interpret + * payloads, only route them into the sandboxed view. Servers should check + * this setting before registering renderer-pattern tools and degrade to + * text-only or `structuredContent`-driven variants when absent. + */ + contentMimeTypes: z.array(z.string()).optional(), }); /** diff --git a/src/server/index.ts b/src/server/index.ts index c90514acd..7d307f7dc 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -62,6 +62,7 @@ import type { // Re-exports for convenience export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE }; export type { ResourceMetadata, ToolCallback }; +export * from "../ui-content.js"; /** * Base tool configuration matching the standard MCP server tool options. diff --git a/src/spec.types.ts b/src/spec.types.ts index 7a8b33761..8f5980b8c 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -728,6 +728,21 @@ export interface McpUiResourceMeta { * - omitted: host decides border */ prefersBorder?: boolean; + /** + * @description MIME types of dynamic content payloads this view renders. + * + * When present, the view acts as a renderer for typed payloads returned by + * its associated tools as embedded resources marked with `_meta.ui.content` + * (see {@link McpUiContentBlockMeta `McpUiContentBlockMeta`}). Does not + * affect the resource's own `mimeType`, which remains + * `"text/html;profile=mcp-app"`. + * + * @example + * ```ts + * ["application/a2ui+json"] + * ``` + */ + contentMimeTypes?: string[]; } /** @@ -795,6 +810,27 @@ export interface McpUiToolMeta { permissions?: never; } +/** + * @description Metadata marking an embedded resource content block in a tool + * result as a dynamic view content payload. + * + * Placed at `_meta.ui.content` on `type: "resource"` content blocks within + * `CallToolResult.content`. Marked payloads are presentation data for the + * tool's view: hosts forward them unmodified to the view (via + * `ui/notifications/tool-result` and proxied `tools/call` responses) and + * exclude them from model context. The payload's `mimeType` must be declared + * in the target view's {@link McpUiResourceMeta.contentMimeTypes `contentMimeTypes`}. + */ +export interface McpUiContentBlockMeta { + /** + * @description URI of the `ui://` renderer resource this payload targets. + * + * If omitted, the payload targets the calling tool's `_meta.ui.resourceUri`. + * Explicit targeting supports future multi-view tool results. + */ + rendererUri?: string; +} + /** * Method string constants for MCP Apps protocol messages. * @@ -855,4 +891,15 @@ export interface McpUiClientCapabilities { * Must include `"text/html;profile=mcp-app"` for MCP Apps support. */ mimeTypes?: string[]; + /** + * @description Dynamic content payload MIME types the host will forward to + * views (see {@link McpUiContentBlockMeta `McpUiContentBlockMeta`}). + * + * Hosts may advertise `["*"]` to indicate they forward any payload type + * declared by a view's `contentMimeTypes` — hosts never need to interpret + * payloads, only route them into the sandboxed view. Servers should check + * this setting before registering renderer-pattern tools and degrade to + * text-only or `structuredContent`-driven variants when absent. + */ + contentMimeTypes?: string[]; } diff --git a/src/types.ts b/src/types.ts index 7fc6b7188..22c6efb2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,7 @@ export { type McpUiRequestDisplayModeResult, type McpUiToolVisibility, type McpUiToolMeta, + type McpUiContentBlockMeta, type McpUiClientCapabilities, } from "./spec.types.js"; @@ -134,6 +135,7 @@ export { McpUiRequestDisplayModeResultSchema, McpUiToolVisibilitySchema, McpUiToolMetaSchema, + McpUiContentBlockMetaSchema, } from "./generated/schema.js"; // Re-export SDK types used in protocol type unions diff --git a/src/ui-content.examples.ts b/src/ui-content.examples.ts new file mode 100644 index 000000000..6911da016 --- /dev/null +++ b/src/ui-content.examples.ts @@ -0,0 +1,78 @@ +/** + * Type-checked examples for {@link ui-content!} helpers. + * + * These examples are included in the API documentation via code fences with + * `source` attributes. Each function's region markers define the code snippet + * that appears in the docs. + * + * @module + */ + +import type { App } from "./app"; +import { + createViewContentBlock, + getViewContentBlocks, + supportsContentMimeType, +} from "./ui-content"; +import type { McpUiClientCapabilities } from "./spec.types"; + +declare const app: App; +declare const uiCap: McpUiClientCapabilities | undefined; +declare function searchFlights(route: string): Promise<{ summary: string }>; +declare function buildA2uiSurface(route: string): string; +declare function renderA2ui(payloads: string[]): void; + +const A2UI_MIME_TYPE = "application/a2ui+json"; + +async function createViewContentBlockExamples(route: string) { + //#region createViewContentBlock_toolResult + // Server: return a typed payload alongside the text fallback + const flights = await searchFlights(route); + return { + content: [ + { type: "text" as const, text: flights.summary }, + createViewContentBlock({ + uri: `a2ui://flight-server/surfaces/${encodeURIComponent(route)}`, + mimeType: A2UI_MIME_TYPE, + text: buildA2uiSurface(route), + }), + ], + }; + //#endregion createViewContentBlock_toolResult +} + +function getViewContentBlocksExamples() { + //#region getViewContentBlocks_ontoolresult + // View: extract payloads from delivered tool results + app.ontoolresult = (result) => { + const payloads = getViewContentBlocks(result, { + mimeType: A2UI_MIME_TYPE, + }); + renderA2ui( + payloads.map((block) => + "text" in block.resource + ? block.resource.text + : atob(block.resource.blob), + ), + ); + }; + //#endregion getViewContentBlocks_ontoolresult +} + +function supportsContentMimeTypeExamples() { + //#region supportsContentMimeType_checkSupport + // Server: register the renderer-pattern tool only when the host + // forwards this payload type (handles the ["*"] wildcard) + if (supportsContentMimeType(uiCap?.contentMimeTypes, A2UI_MIME_TYPE)) { + // register tool returning marked A2UI payloads + } else { + // register text-only or structuredContent-driven variant + } + //#endregion supportsContentMimeType_checkSupport +} + +export { + createViewContentBlockExamples, + getViewContentBlocksExamples, + supportsContentMimeTypeExamples, +}; diff --git a/src/ui-content.test.ts b/src/ui-content.test.ts new file mode 100644 index 000000000..d8e667353 --- /dev/null +++ b/src/ui-content.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "bun:test"; +import { + createViewContentBlock, + getViewContentBlocks, + isViewContentBlock, + supportsContentMimeType, +} from "./ui-content"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +const A2UI_MIME_TYPE = "application/a2ui+json"; + +describe("createViewContentBlock", () => { + it("should create a marked embedded resource block from text", () => { + const block = createViewContentBlock({ + uri: "a2ui://server/surfaces/1", + mimeType: A2UI_MIME_TYPE, + text: '{"beginRendering":{}}', + }); + expect(block).toEqual({ + type: "resource", + resource: { + uri: "a2ui://server/surfaces/1", + mimeType: A2UI_MIME_TYPE, + text: '{"beginRendering":{}}', + }, + _meta: { ui: { content: {} } }, + }); + }); + + it("should create a block from blob", () => { + const block = createViewContentBlock({ + uri: "data://server/1", + mimeType: "application/octet-stream", + blob: "AAAA", + }); + expect(block.resource).toEqual({ + uri: "data://server/1", + mimeType: "application/octet-stream", + blob: "AAAA", + }); + }); + + it("should include rendererUri in the marker when provided", () => { + const block = createViewContentBlock({ + uri: "a2ui://server/surfaces/1", + mimeType: A2UI_MIME_TYPE, + text: "{}", + rendererUri: "ui://server/renderer", + }); + expect(block._meta.ui.content).toEqual({ + rendererUri: "ui://server/renderer", + }); + }); + + it("should reject providing neither or both of text and blob", () => { + expect(() => + createViewContentBlock({ uri: "a2ui://x", mimeType: A2UI_MIME_TYPE }), + ).toThrow("exactly one of `text` or `blob`"); + expect(() => + createViewContentBlock({ + uri: "a2ui://x", + mimeType: A2UI_MIME_TYPE, + text: "{}", + blob: "AAAA", + }), + ).toThrow("exactly one of `text` or `blob`"); + }); + + it("should reject ui:// payload URIs", () => { + expect(() => + createViewContentBlock({ + uri: "ui://server/renderer", + mimeType: A2UI_MIME_TYPE, + text: "{}", + }), + ).toThrow("ui://"); + }); +}); + +describe("isViewContentBlock", () => { + it("should accept marked embedded resources", () => { + const block = createViewContentBlock({ + uri: "a2ui://x", + mimeType: A2UI_MIME_TYPE, + text: "{}", + }); + expect(isViewContentBlock(block)).toBe(true); + }); + + it("should reject unmarked and non-resource blocks", () => { + expect(isViewContentBlock({ type: "text", text: "hi" })).toBe(false); + expect( + isViewContentBlock({ + type: "resource", + resource: { uri: "file://x", mimeType: "text/plain", text: "hi" }, + }), + ).toBe(false); + expect( + isViewContentBlock({ + type: "resource", + resource: { uri: "file://x", mimeType: "text/plain", text: "hi" }, + _meta: { ui: {} }, + }), + ).toBe(false); + }); +}); + +describe("getViewContentBlocks", () => { + const result: Pick = { + content: [ + { type: "text", text: "Found 3 flights" }, + createViewContentBlock({ + uri: "a2ui://server/surfaces/1", + mimeType: A2UI_MIME_TYPE, + text: "{}", + }), + { + type: "resource", + resource: { uri: "file://report", mimeType: "text/csv", text: "a,b" }, + }, + createViewContentBlock({ + uri: "custom://server/2", + mimeType: "application/vnd.custom+json", + text: "{}", + rendererUri: "ui://server/other-renderer", + }), + ], + }; + + it("should return only marked blocks, in order", () => { + const blocks = getViewContentBlocks(result); + expect(blocks.map((b) => b.resource.uri)).toEqual([ + "a2ui://server/surfaces/1", + "custom://server/2", + ]); + }); + + it("should filter by mimeType", () => { + const blocks = getViewContentBlocks(result, { mimeType: A2UI_MIME_TYPE }); + expect(blocks.map((b) => b.resource.uri)).toEqual([ + "a2ui://server/surfaces/1", + ]); + }); + + it("should filter by rendererUri, including untargeted blocks", () => { + expect( + getViewContentBlocks(result, { + rendererUri: "ui://server/other-renderer", + }).map((b) => b.resource.uri), + ).toEqual(["a2ui://server/surfaces/1", "custom://server/2"]); + expect( + getViewContentBlocks(result, { + rendererUri: "ui://server/renderer", + }).map((b) => b.resource.uri), + ).toEqual(["a2ui://server/surfaces/1"]); + }); + + it("should handle results with no content", () => { + expect(getViewContentBlocks({ content: [] })).toEqual([]); + }); +}); + +describe("supportsContentMimeType", () => { + it("should match declared MIME types", () => { + expect(supportsContentMimeType([A2UI_MIME_TYPE], A2UI_MIME_TYPE)).toBe( + true, + ); + expect(supportsContentMimeType(["text/plain"], A2UI_MIME_TYPE)).toBe(false); + }); + + it("should honor the wildcard", () => { + expect(supportsContentMimeType(["*"], A2UI_MIME_TYPE)).toBe(true); + }); + + it("should return false when unset", () => { + expect(supportsContentMimeType(undefined, A2UI_MIME_TYPE)).toBe(false); + expect(supportsContentMimeType([], A2UI_MIME_TYPE)).toBe(false); + }); +}); diff --git a/src/ui-content.ts b/src/ui-content.ts new file mode 100644 index 000000000..f6e961efc --- /dev/null +++ b/src/ui-content.ts @@ -0,0 +1,218 @@ +/** + * Helpers for **Dynamic View Content**: typed presentation payloads carried in + * tool results as embedded resource content blocks marked with + * `_meta.ui.content`. + * + * A view declares the payload MIME types it renders via + * {@link types!McpUiResourceMeta.contentMimeTypes `contentMimeTypes`} on its UI + * resource. Tools associated with that view return payloads as marked embedded + * resources ({@link createViewContentBlock `createViewContentBlock`}), hosts + * forward them unmodified, and the view extracts them from tool results + * ({@link getViewContentBlocks `getViewContentBlocks`}). + * + * @module + */ +import type { + CallToolResult, + ContentBlock, + EmbeddedResource, +} from "@modelcontextprotocol/sdk/types.js"; +import type { McpUiContentBlockMeta } from "./spec.types"; + +/** + * An embedded resource content block marked as a dynamic view content payload. + * + * @see {@link isViewContentBlock `isViewContentBlock`} to narrow a content block to this type + */ +export type ViewContentBlock = EmbeddedResource & { + _meta: { + ui: { + content: McpUiContentBlockMeta; + }; + }; +}; + +/** + * Options for creating a dynamic view content block. + * + * @see {@link createViewContentBlock `createViewContentBlock`} + */ +export type CreateViewContentBlockOptions = { + /** + * Ephemeral payload identifier (any scheme except `ui://`, which is + * reserved for renderable UI resources). + */ + uri: string; + /** + * Payload MIME type. Must be declared in the target view's + * `contentMimeTypes`. + */ + mimeType: string; + /** Payload as a string. Exactly one of `text` or `blob` must be provided. */ + text?: string; + /** Base64-encoded payload. Exactly one of `text` or `blob` must be provided. */ + blob?: string; + /** + * URI of the `ui://` renderer resource this payload targets. If omitted, + * the payload targets the calling tool's `_meta.ui.resourceUri`. + */ + rendererUri?: string; +}; + +/** + * Create an embedded resource content block marked as dynamic view content. + * + * Servers include the returned block in `CallToolResult.content`. Hosts that + * negotiated dynamic content support forward it unmodified to the tool's view + * (and exclude it from model context). + * + * @example + * ```ts source="./ui-content.examples.ts#createViewContentBlock_toolResult" + * // Server: return a typed payload alongside the text fallback + * const flights = await searchFlights(route); + * return { + * content: [ + * { type: "text" as const, text: flights.summary }, + * createViewContentBlock({ + * uri: `a2ui://flight-server/surfaces/${encodeURIComponent(route)}`, + * mimeType: A2UI_MIME_TYPE, + * text: buildA2uiSurface(route), + * }), + * ], + * }; + * ``` + * + * @see {@link getViewContentBlocks `getViewContentBlocks`} for the view-side extraction helper + */ +export function createViewContentBlock( + options: CreateViewContentBlockOptions, +): ViewContentBlock { + const { uri, mimeType, text, blob, rendererUri } = options; + if ((text === undefined) === (blob === undefined)) { + throw new Error( + "createViewContentBlock: exactly one of `text` or `blob` must be provided", + ); + } + if (uri.startsWith("ui://")) { + throw new Error( + "createViewContentBlock: payload URIs must not use the ui:// scheme (reserved for renderable UI resources)", + ); + } + return { + type: "resource", + resource: + text !== undefined + ? { uri, mimeType, text } + : { uri, mimeType, blob: blob! }, + _meta: { + ui: { + content: rendererUri !== undefined ? { rendererUri } : {}, + }, + }, + }; +} + +/** + * Check whether a content block is a dynamic view content payload (an + * embedded resource marked with `_meta.ui.content`). + */ +export function isViewContentBlock( + block: ContentBlock, +): block is ViewContentBlock { + if (block.type !== "resource") { + return false; + } + const ui = block._meta?.["ui"]; + if (typeof ui !== "object" || ui === null) { + return false; + } + const content = (ui as Record)["content"]; + return typeof content === "object" && content !== null; +} + +/** + * Options for extracting dynamic view content payloads from a tool result. + * + * @see {@link getViewContentBlocks `getViewContentBlocks`} + */ +export type GetViewContentBlocksOptions = { + /** Only return payloads with this MIME type. */ + mimeType?: string; + /** + * Only return payloads targeting this renderer: blocks whose + * `_meta.ui.content.rendererUri` equals this URI, plus blocks with no + * explicit `rendererUri` (which target the calling tool's default view). + */ + rendererUri?: string; +}; + +/** + * Extract dynamic view content payloads from a tool result, in array order. + * + * Views use this on results delivered via `ui/notifications/tool-result` + * (the {@link app!App.ontoolresult `ontoolresult`} handler) and on results + * returned from {@link app!App.callServerTool `callServerTool`}. + * + * @example + * ```ts source="./ui-content.examples.ts#getViewContentBlocks_ontoolresult" + * // View: extract payloads from delivered tool results + * app.ontoolresult = (result) => { + * const payloads = getViewContentBlocks(result, { + * mimeType: A2UI_MIME_TYPE, + * }); + * renderA2ui( + * payloads.map((block) => + * "text" in block.resource + * ? block.resource.text + * : atob(block.resource.blob), + * ), + * ); + * }; + * ``` + */ +export function getViewContentBlocks( + result: Pick, + options: GetViewContentBlocksOptions = {}, +): ViewContentBlock[] { + const { mimeType, rendererUri } = options; + return (result.content ?? []).filter(isViewContentBlock).filter((block) => { + if (mimeType !== undefined && block.resource.mimeType !== mimeType) { + return false; + } + if (rendererUri !== undefined) { + const target = block._meta.ui.content.rendererUri; + return target === undefined || target === rendererUri; + } + return true; + }); +} + +/** + * Check whether a payload MIME type is included in a set of supported + * dynamic content MIME types, honoring the `["*"]` wildcard. + * + * Servers use this against the host's negotiated + * {@link types!McpUiClientCapabilities.contentMimeTypes `contentMimeTypes`} + * extension setting before registering renderer-pattern tools; hosts can use + * it against a view's declared `contentMimeTypes` to type-filter payloads. + * + * @example + * ```ts source="./ui-content.examples.ts#supportsContentMimeType_checkSupport" + * // Server: register the renderer-pattern tool only when the host + * // forwards this payload type (handles the ["*"] wildcard) + * if (supportsContentMimeType(uiCap?.contentMimeTypes, A2UI_MIME_TYPE)) { + * // register tool returning marked A2UI payloads + * } else { + * // register text-only or structuredContent-driven variant + * } + * ``` + */ +export function supportsContentMimeType( + contentMimeTypes: string[] | undefined, + mimeType: string, +): boolean { + if (!contentMimeTypes) { + return false; + } + return contentMimeTypes.includes("*") || contentMimeTypes.includes(mimeType); +} diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index a3c30d8a8..d96f86f2e 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -117,6 +117,11 @@ const ALL_SERVERS = [ dir: "customer-segmentation-server", }, { key: "debug-server", name: "Debug MCP App Server", dir: "debug-server" }, + { + key: "dynamic-content", + name: "Dynamic View Content Example Server", + dir: "dynamic-content-server", + }, { key: "map-server", name: "CesiumJS Map Server", dir: "map-server" }, { key: "pdf-server", name: "PDF Server", dir: "pdf-server" }, { key: "qr-server", name: "QR Code Server", dir: "qr-server" }, diff --git a/tests/e2e/servers.spec.ts-snapshots/dynamic-content.png b/tests/e2e/servers.spec.ts-snapshots/dynamic-content.png new file mode 100644 index 000000000..3f387fc68 Binary files /dev/null and b/tests/e2e/servers.spec.ts-snapshots/dynamic-content.png differ