diff --git a/AGENTS.md b/AGENTS.md index 77018851..623d561b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,8 @@ Guest UI (App) <--PostMessageTransport--> Host (AppBridge) <--MCP Client--> MCP JSDoc `@example` tags use `{@includeCode ./file.examples.ts#regionName}` to pull in type-checked code from companion `.examples.ts`/`.examples.tsx` files. Regions are marked with `//#region name` and `//#endregion name`, wrapped in functions (whose parameters provide types for external values). Region names follow `exportedName_variant` or `ClassName_methodName_variant` pattern (e.g., `useApp_basicUsage`, `App_hostCapabilities_checkAfterConnection`). +Standalone docs in `docs/` (listed in `typedoc.config.mjs` `projectDocuments`) can also have type-checked companion `.ts`/`.tsx` files using the same `@includeCode` pattern. + ## Full Examples Uses npm workspaces. Full examples in `examples/` are separate packages: diff --git a/docs/migrate_from_openai_apps.md b/docs/migrate_from_openai_apps.md index d681c34c..c7b43a0f 100644 --- a/docs/migrate_from_openai_apps.md +++ b/docs/migrate_from_openai_apps.md @@ -1,3 +1,7 @@ +--- +title: Migrate OpenAI App +--- + # Migrating from OpenAI Apps SDK to MCP Apps SDK This guide helps you migrate from the OpenAI Apps SDK to the MCP Apps SDK (`@modelcontextprotocol/ext-apps`). @@ -50,7 +54,7 @@ This guide helps you migrate from the OpenAI Apps SDK to the MCP Apps SDK (`@mod ### Server-Side Migration Example -### Before (OpenAI) +#### Before (OpenAI) ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -110,7 +114,7 @@ function createServer() { } ``` -### After (MCP Apps) +#### After (MCP Apps) ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -184,7 +188,7 @@ function createServer() { 6. **Helper Functions**: MCP provides `registerAppTool()` and `registerAppResource()` helpers 7. **Not Yet Implemented**: `_meta["openai/toolInvocation/invoking"]`, `_meta["openai/toolInvocation/invoked"]`, and `_meta["openai/widgetDescription"]` don't have MCP equivalents yet -## Client-side +## Client-Side ### Quick Start Comparison @@ -302,7 +306,7 @@ function createServer() { | — | `app.getHostVersion()` | Returns `{ name, version }` of host | | — | `app.getHostCapabilities()` | Check `serverTools`, `openLinks`, `logging`, etc. | -### Full Migration Example +### Client-Side Migration Example #### Before (OpenAI) diff --git a/docs/patterns.md b/docs/patterns.md new file mode 100644 index 00000000..abe80179 --- /dev/null +++ b/docs/patterns.md @@ -0,0 +1,116 @@ +--- +title: Patterns +--- + +# MCP Apps Patterns + +This document covers common patterns and recipes for building MCP Apps. + +## Tools that are private to Apps + +Set {@link types!McpUiToolMeta.visibility Tool.\_meta.ui.visibility} to `["app"]` to make tools only callable by Apps (hidden from the model). This is useful for UI-driven actions like updating quantities, toggling settings, or other interactions that shouldn't appear in the model's tool list. + +{@includeCode ../src/server/index.examples.ts#registerAppTool_appOnlyVisibility} + +_See [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) for a full implementation of this pattern._ + +## Reading large amounts of data via chunked tool calls + +Some host platforms have size limits on tool call responses, so large files (PDFs, images, etc.) cannot be sent in a single response. Use an app-only tool with chunked responses to bypass these limits while keeping the data out of model context. + +**Server-side**: Register an app-only tool that returns data in chunks with pagination metadata: + +{@includeCode ./patterns.tsx#chunkedDataServer} + +**Client-side**: Loop calling the tool until all chunks are received: + +{@includeCode ./patterns.tsx#chunkedDataClient} + +_See [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) for a full implementation of this pattern._ + +## Giving errors back to model + +**Server-side**: Tool handler validates inputs and returns `{ isError: true, content: [...] }`. The model receives this error through the normal tool call response. + +**Client-side**: If a runtime error occurs (e.g., API failure, permission denied, resource unavailable), use {@link app!App.updateModelContext updateModelContext} to inform the model: + +{@includeCode ../src/app.examples.ts#App_updateModelContext_reportError} + +## Matching host styling (CSS variables, theme, and fonts) + +Use the SDK's style helpers to apply host styling, then reference them in your CSS: + +- **CSS variables** — Use `var(--color-background-primary)`, etc. in your CSS +- **Theme** — Use `[data-theme="dark"]` selectors or `light-dark()` function for theme-aware styles +- **Fonts** — Use `var(--font-sans)` or `var(--font-mono)` with fallbacks (e.g., `font-family: var(--font-sans, system-ui, sans-serif)`) + +**Vanilla JS:** + +{@includeCode ./patterns.tsx#hostStylingVanillaJs} + +**React:** + +{@includeCode ./patterns.tsx#hostStylingReact} + +_See [`examples/basic-server-vanillajs/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) and [`examples/basic-server-react/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) for full implementations of this pattern._ + +## Entering / Exiting fullscreen + +Toggle fullscreen mode by calling {@link app!App.requestDisplayMode requestDisplayMode}: + +{@includeCode ../src/app.examples.ts#App_requestDisplayMode_toggle} + +Listen for display mode changes via {@link app!App.onhostcontextchanged onhostcontextchanged} to update your UI: + +{@includeCode ../src/app.examples.ts#App_onhostcontextchanged_respondToDisplayMode} + +_See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) for a full implementation of this pattern._ + +## Passing contextual information from the App to the Model + +Use {@link app!App.updateModelContext updateModelContext} to keep the model informed about what the user is viewing or interacting with. Structure the content with YAML frontmatter for easy parsing: + +{@includeCode ../src/app.examples.ts#App_updateModelContext_appState} + +_See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) for a full implementation of this pattern._ + +## Sending large follow-up messages + +When you need to send more data than fits in a message, use {@link app!App.updateModelContext updateModelContext} to set the context first, then {@link app!App.sendMessage sendMessage} with a brief prompt to trigger a response: + +{@includeCode ../src/app.examples.ts#App_sendMessage_withLargeContext} + +_See [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server) for a full implementation of this pattern._ + +## Persisting widget state + +To persist widget state across conversation reloads (e.g., current page in a PDF viewer, camera position in a map), use [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) with a stable identifier provided by the server. + +**Server-side**: Tool handler generates a unique `widgetUUID` and returns it in `CallToolResult._meta.widgetUUID`: + +{@includeCode ./patterns.tsx#persistDataServer} + +**Client-side**: Receive the UUID in {@link app!App.ontoolresult ontoolresult} and use it as the storage key: + +{@includeCode ./patterns.tsx#persistData} + +_See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) for a full implementation of this pattern._ + +## Pausing computation-heavy widgets when out of view + +Widgets with animations, WebGL rendering, or polling can consume significant CPU/GPU even when scrolled out of view. Use [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to pause expensive operations when the widget isn't visible: + +{@includeCode ./patterns.tsx#visibilityBasedPause} + +_See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) for a full implementation of this pattern._ + +## Lowering perceived latency + +Use {@link app!App.ontoolinputpartial ontoolinputpartial} to receive streaming tool arguments as they arrive, allowing you to show a loading preview before the complete input is available. + +{@includeCode ../src/app.examples.ts#App_ontoolinputpartial_progressiveRendering} + +> [!IMPORTANT] +> Partial arguments are "healed" JSON — the host closes unclosed brackets/braces to produce valid JSON. This means objects may be incomplete (e.g., the last item in an array may be truncated). Don't rely on partial data for critical operations; use it only for preview UI. + +_See [`examples/threejs-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server) for a full implementation of this pattern._ diff --git a/docs/patterns.tsx b/docs/patterns.tsx new file mode 100644 index 00000000..f21ff5c9 --- /dev/null +++ b/docs/patterns.tsx @@ -0,0 +1,315 @@ +/** + * Type-checked code examples for the patterns documentation. + * + * These examples are included in {@link ./patterns.md} via `@includeCode` tags. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { App } from "../src/app.js"; +import { + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, +} from "../src/styles.js"; +import { randomUUID } from "node:crypto"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { McpUiHostContext } from "../src/types.js"; +import { useApp, useHostStyles } from "../src/react/index.js"; +import { registerAppTool } from "../src/server/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +/** + * Example: Server-side chunked data tool (app-only) + */ +function chunkedDataServer(server: McpServer) { + //#region chunkedDataServer + // Define the chunk response schema + const DataChunkSchema = z.object({ + bytes: z.string(), // base64-encoded data + offset: z.number(), + byteCount: z.number(), + totalBytes: z.number(), + hasMore: z.boolean(), + }); + + const MAX_CHUNK_BYTES = 500 * 1024; // 500KB per chunk + + registerAppTool( + server, + "read_data_bytes", + { + title: "Read Data Bytes", + description: "Load binary data in chunks", + inputSchema: { + id: z.string().describe("Resource identifier"), + offset: z.number().min(0).default(0).describe("Byte offset"), + byteCount: z + .number() + .default(MAX_CHUNK_BYTES) + .describe("Bytes to read"), + }, + outputSchema: DataChunkSchema, + // Hidden from model - only callable by the App + _meta: { ui: { visibility: ["app"] } }, + }, + async ({ id, offset, byteCount }): Promise => { + const data = await loadData(id); // Your data loading logic + const chunk = data.slice(offset, offset + byteCount); + + return { + content: [{ type: "text", text: `${chunk.length} bytes at ${offset}` }], + structuredContent: { + bytes: Buffer.from(chunk).toString("base64"), + offset, + byteCount: chunk.length, + totalBytes: data.length, + hasMore: offset + chunk.length < data.length, + }, + }; + }, + ); + //#endregion chunkedDataServer +} + +// Stub for the example +declare function loadData(id: string): Promise; + +/** + * Example: Client-side chunked data loading + */ +function chunkedDataClient(app: App, resourceId: string) { + //#region chunkedDataClient + interface DataChunk { + bytes: string; // base64 + offset: number; + byteCount: number; + totalBytes: number; + hasMore: boolean; + } + + async function loadDataInChunks( + id: string, + onProgress?: (loaded: number, total: number) => void, + ): Promise { + const CHUNK_SIZE = 500 * 1024; // 500KB chunks + const chunks: Uint8Array[] = []; + let offset = 0; + let totalBytes = 0; + let hasMore = true; + + while (hasMore) { + const result = await app.callServerTool({ + name: "read_data_bytes", + arguments: { id, offset, byteCount: CHUNK_SIZE }, + }); + + if (result.isError || !result.structuredContent) { + throw new Error("Failed to load data chunk"); + } + + const chunk = result.structuredContent as unknown as DataChunk; + totalBytes = chunk.totalBytes; + hasMore = chunk.hasMore; + + // Decode base64 to bytes + const binaryString = atob(chunk.bytes); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + chunks.push(bytes); + + offset += chunk.byteCount; + onProgress?.(offset, totalBytes); + } + + // Combine all chunks into single array + const fullData = new Uint8Array(totalBytes); + let pos = 0; + for (const chunk of chunks) { + fullData.set(chunk, pos); + pos += chunk.length; + } + + return fullData; + } + + // Usage: load data with progress updates + loadDataInChunks(resourceId, (loaded, total) => { + console.log(`Loading: ${Math.round((loaded / total) * 100)}%`); + }).then((data) => { + console.log(`Loaded ${data.length} bytes`); + }); + //#endregion chunkedDataClient +} + +/** + * Example: Unified host styling (theme, CSS variables, fonts) + */ +function hostStylingVanillaJs(app: App) { + //#region hostStylingVanillaJs + function applyHostContext(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); + } + } + + // Apply when host context changes + app.onhostcontextchanged = applyHostContext; + + // Apply initial styles after connecting + app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx) { + applyHostContext(ctx); + } + }); + //#endregion hostStylingVanillaJs +} + +/** + * Example: Host styling with React (CSS variables, theme, fonts) + */ +function hostStylingReact() { + //#region hostStylingReact + function MyApp() { + const { app } = useApp({ + appInfo: { name: "MyApp", version: "1.0.0" }, + capabilities: {}, + }); + + // Apply all host styles (variables, theme, fonts) + useHostStyles(app, app?.getHostContext()); + + return ( +
+

Styled with host CSS variables and fonts

+

Uses [data-theme] selectors

+
+ ); + } + //#endregion hostStylingReact +} + +/** + * Example: Persisting widget state (server-side) + */ +function persistWidgetStateServer( + url: string, + title: string, + pageCount: number, +) { + function toolCallback(): CallToolResult { + //#region persistDataServer + // In your tool callback, include widgetUUID in the result metadata. + return { + content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }], + structuredContent: { url, title, pageCount, initialPage: 1 }, + _meta: { + widgetUUID: randomUUID(), + }, + }; + //#endregion persistDataServer + } +} + +/** + * Example: Persisting widget state (client-side) + */ +function persistWidgetState(app: App) { + //#region persistData + // Store the widgetUUID received from the server + let widgetUUID: string | undefined; + + // Helper to save state to localStorage + function saveState(state: T): void { + if (!widgetUUID) return; + try { + localStorage.setItem(widgetUUID, JSON.stringify(state)); + } catch (err) { + console.error("Failed to save widget state:", err); + } + } + + // Helper to load state from localStorage + function loadState(): T | null { + if (!widgetUUID) return null; + try { + const saved = localStorage.getItem(widgetUUID); + return saved ? (JSON.parse(saved) as T) : null; + } catch (err) { + console.error("Failed to load widget state:", err); + return null; + } + } + + // Receive widgetUUID from the tool result + app.ontoolresult = (result) => { + widgetUUID = result._meta?.widgetUUID + ? String(result._meta.widgetUUID) + : undefined; + + // Restore any previously saved state + const savedState = loadState<{ currentPage: number }>(); + if (savedState) { + // Apply restored state to your UI... + } + }; + + // Call saveState() whenever your widget state changes + // e.g., saveState({ currentPage: 5 }); + //#endregion persistData +} + +/** + * Example: Pausing computation-heavy widgets when out of view + */ +function visibilityBasedPause( + app: App, + container: HTMLElement, + animation: { play: () => void; pause: () => void }, +) { + //#region visibilityBasedPause + // Use IntersectionObserver to pause when widget scrolls out of view + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + animation.play(); + } else { + animation.pause(); + } + }); + }); + observer.observe(container); + + // Clean up when the host tears down the widget + app.onteardown = async () => { + observer.disconnect(); + animation.pause(); + return {}; + }; + //#endregion visibilityBasedPause +} + +// Suppress unused variable warnings +void chunkedDataServer; +void chunkedDataClient; +void hostStylingVanillaJs; +void hostStylingReact; +void persistWidgetStateServer; +void persistWidgetState; +void visibilityBasedPause; diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index b5b24194..28bf9367 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -769,16 +769,12 @@ function handleHostContextChanged(ctx: McpUiHostContext) { if (ctx.displayMode) { const wasFullscreen = currentDisplayMode === "fullscreen"; currentDisplayMode = ctx.displayMode as "inline" | "fullscreen"; - if (ctx.displayMode === "fullscreen") { - mainEl.classList.add("fullscreen"); - log.info("Fullscreen mode enabled"); - } else { - mainEl.classList.remove("fullscreen"); - log.info("Inline mode"); - // When exiting fullscreen, request resize to fit content - if (wasFullscreen && pdfDocument) { - requestFitToContent(); - } + const isFullscreen = currentDisplayMode === "fullscreen"; + mainEl.classList.toggle("fullscreen", isFullscreen); + log.info(isFullscreen ? "Fullscreen mode enabled" : "Inline mode"); + // When exiting fullscreen, request resize to fit content + if (wasFullscreen && !isFullscreen && pdfDocument) { + requestFitToContent(); } updateFullscreenButton(); } diff --git a/examples/shadertoy-server/src/mcp-app.ts b/examples/shadertoy-server/src/mcp-app.ts index d19c8f19..546de5af 100644 --- a/examples/shadertoy-server/src/mcp-app.ts +++ b/examples/shadertoy-server/src/mcp-app.ts @@ -65,21 +65,14 @@ function handleHostContextChanged(ctx: McpUiHostContext) { // Show fullscreen button if available (only update if field is present) if (ctx.availableDisplayModes !== undefined) { - if (ctx.availableDisplayModes.includes("fullscreen")) { - fullscreenBtn.classList.add("available"); - } else { - fullscreenBtn.classList.remove("available"); - } + const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); + fullscreenBtn.classList.toggle("available", canFullscreen); } // Update display mode state and UI if (ctx.displayMode) { currentDisplayMode = ctx.displayMode as "inline" | "fullscreen"; - if (currentDisplayMode === "fullscreen") { - mainEl.classList.add("fullscreen"); - } else { - mainEl.classList.remove("fullscreen"); - } + mainEl.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); } } @@ -96,11 +89,7 @@ async function toggleFullscreen() { try { const result = await app.requestDisplayMode({ mode: newMode }); currentDisplayMode = result.mode as "inline" | "fullscreen"; - if (currentDisplayMode === "fullscreen") { - mainEl.classList.add("fullscreen"); - } else { - mainEl.classList.remove("fullscreen"); - } + mainEl.classList.toggle("fullscreen", currentDisplayMode === "fullscreen"); } catch (err) { log.error("Failed to change display mode:", err); } diff --git a/src/app.examples.ts b/src/app.examples.ts index 7ff766d1..5c6a4ce1 100644 --- a/src/app.examples.ts +++ b/src/app.examples.ts @@ -81,18 +81,6 @@ async function App_basicUsage() { //#endregion App_basicUsage } -/** - * Example: Sending a message to the host's chat. - */ -async function App_sendMessage(app: App) { - //#region App_sendMessage - await app.sendMessage({ - role: "user", - content: [{ type: "text", text: "Weather updated!" }], - }); - //#endregion App_sendMessage -} - /** * Example: Check host capabilities after connection. */ @@ -157,13 +145,34 @@ async function App_ontoolinput_setter(app: App) { */ function App_ontoolinputpartial_progressiveRendering(app: App) { //#region App_ontoolinputpartial_progressiveRendering + let toolInputs: Record | null = null; + let toolInputsPartial: Record | null = null; + app.ontoolinputpartial = (params) => { - console.log("Partial args:", params.arguments); - // Update your UI progressively as arguments stream in + toolInputsPartial = params.arguments as Record; + render(); + }; + + app.ontoolinput = (params) => { + toolInputs = params.arguments as Record; + toolInputsPartial = null; + render(); }; + + function render() { + if (toolInputs) { + renderFinalUI(toolInputs); + } else { + renderLoadingUI(toolInputsPartial); // e.g., shimmer with partial preview + } + } //#endregion App_ontoolinputpartial_progressiveRendering } +// Stubs for App_ontoolinputpartial_progressiveRendering example +declare function renderLoadingUI(data: Record | null): void; +declare function renderFinalUI(data: Record): void; + /** * Example: Display tool execution results using ontoolresult. */ @@ -206,6 +215,20 @@ function App_onhostcontextchanged_respondToTheme(app: App) { //#endregion App_onhostcontextchanged_respondToTheme } +/** + * Example: Respond to display mode changes using onhostcontextchanged. + */ +function App_onhostcontextchanged_respondToDisplayMode(app: App) { + //#region App_onhostcontextchanged_respondToDisplayMode + app.onhostcontextchanged = (params) => { + if (params.displayMode) { + const isFullscreen = params.displayMode === "fullscreen"; + document.body.classList.toggle("fullscreen", isFullscreen); + } + }; + //#endregion App_onhostcontextchanged_respondToDisplayMode +} + /** * Example: Perform cleanup before teardown. */ @@ -294,6 +317,33 @@ async function App_sendMessage_textFromInteraction(app: App) { //#endregion App_sendMessage_textFromInteraction } +/** + * Example: Send follow-up message after offloading large data to model context. + */ +async function App_sendMessage_withLargeContext( + app: App, + fullTranscript: string, + speakerNames: string[], +) { + //#region App_sendMessage_withLargeContext + const markdown = `--- +word-count: ${fullTranscript.split(/\s+/).length} +speaker-names: ${speakerNames.join(", ")} +--- + +${fullTranscript}`; + + // Offload long transcript to model context + await app.updateModelContext({ content: [{ type: "text", text: markdown }] }); + + // Send brief trigger message + await app.sendMessage({ + role: "user", + content: [{ type: "text", text: "Summarize the key points" }], + }); + //#endregion App_sendMessage_withLargeContext +} + /** * Example: Log app state for debugging. */ @@ -310,23 +360,49 @@ function App_sendLog_debugState(app: App) { /** * Example: Update model context with current app state. */ -async function App_updateModelContext_appState(app: App) { +async function App_updateModelContext_appState( + app: App, + itemList: string[], + totalCost: string, + currency: string, +) { //#region App_updateModelContext_appState + const markdown = `--- +item-count: ${itemList.length} +total-cost: ${totalCost} +currency: ${currency} +--- + +User is viewing their shopping cart with ${itemList.length} items selected: + +${itemList.map((item) => `- ${item}`).join("\n")}`; + await app.updateModelContext({ - content: [{ type: "text", text: "User selected 3 items totaling $150.00" }], + content: [{ type: "text", text: markdown }], }); //#endregion App_updateModelContext_appState } /** - * Example: Update with structured content. + * Example: Report runtime error to model. */ -async function App_updateModelContext_structuredContent(app: App) { - //#region App_updateModelContext_structuredContent - await app.updateModelContext({ - structuredContent: { selectedItems: 3, total: 150.0, currency: "USD" }, - }); - //#endregion App_updateModelContext_structuredContent +async function App_updateModelContext_reportError(app: App) { + //#region App_updateModelContext_reportError + try { + const _stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + // ... use _stream for transcription + } catch (err) { + // Inform the model that the app is in a degraded state + await app.updateModelContext({ + content: [ + { + type: "text", + text: "Error: transcription unavailable", + }, + ], + }); + } + //#endregion App_updateModelContext_reportError } /** @@ -344,16 +420,17 @@ async function App_openLink_documentation(app: App) { } /** - * Example: Request fullscreen mode. + * Example: Toggle between inline and fullscreen display modes. */ -async function App_requestDisplayMode_fullscreen(app: App) { - //#region App_requestDisplayMode_fullscreen - const context = app.getHostContext(); - if (context?.availableDisplayModes?.includes("fullscreen")) { - const result = await app.requestDisplayMode({ mode: "fullscreen" }); - console.log("Display mode set to:", result.mode); +async function App_requestDisplayMode_toggle(app: App) { + //#region App_requestDisplayMode_toggle + const ctx = app.getHostContext(); + if (ctx?.availableDisplayModes?.includes("fullscreen")) { + const target = ctx.displayMode === "fullscreen" ? "inline" : "fullscreen"; + const result = await app.requestDisplayMode({ mode: target }); + console.log("Now in:", result.mode); } - //#endregion App_requestDisplayMode_fullscreen + //#endregion App_requestDisplayMode_toggle } /** diff --git a/src/app.ts b/src/app.ts index dee12b86..4fa87765 100644 --- a/src/app.ts +++ b/src/app.ts @@ -156,9 +156,6 @@ type RequestHandlerExtra = Parameters< * * @example Basic usage with PostMessageTransport * {@includeCode ./app.examples.ts#App_basicUsage} - * - * @example Sending a message to the host's chat - * {@includeCode ./app.examples.ts#App_sendMessage} */ export class App extends Protocol { private _hostCapabilities?: McpUiHostCapabilities; @@ -287,6 +284,11 @@ export class App extends Protocol { * streams partial tool arguments during tool call initialization. This enables * progressive rendering of tool arguments before they're complete. * + * **Important:** Partial arguments are "healed" JSON — the host closes unclosed + * brackets/braces to produce valid JSON. This means objects may be incomplete + * (e.g., the last item in an array may be truncated). Use partial data only + * for preview UI, not for critical operations. + * * This setter is a convenience wrapper around `setNotificationHandler()` that * automatically handles the notification schema and extracts the params for you. * @@ -613,6 +615,9 @@ export class App extends Protocol { * @example Send a text message from user interaction * {@includeCode ./app.examples.ts#App_sendMessage_textFromInteraction} * + * @example Send follow-up message after offloading large data to model context + * {@includeCode ./app.examples.ts#App_sendMessage_withLargeContext} + * * @see {@link McpUiMessageRequest} for request structure */ sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) { @@ -649,13 +654,12 @@ export class App extends Protocol { /** * Update the host's model context with app state. * - * Unlike `sendLog`, which is for debugging/telemetry, context updates - * are intended to be available to the model in future reasoning, - * without requiring a follow-up action (like `sendMessage`). + * Context updates are intended to be available to the model in future + * turns, without triggering an immediate model response (unlike {@link sendMessage}). * * The host will typically defer sending the context to the model until the - * next user message (including `ui/message`), and will only send the last - * update received. Each call overwrites any previous context update. + * next user message — either from the actual user or via `sendMessage`. Only + * the last update is sent; each call overwrites any previous context. * * @param params - Context content and/or structured content * @param options - Request options (timeout, etc.) @@ -666,8 +670,8 @@ export class App extends Protocol { * @example Update model context with current app state * {@includeCode ./app.examples.ts#App_updateModelContext_appState} * - * @example Update with structured content - * {@includeCode ./app.examples.ts#App_updateModelContext_structuredContent} + * @example Report runtime error to model + * {@includeCode ./app.examples.ts#App_updateModelContext_reportError} * * @returns Promise that resolves when the context update is acknowledged */ @@ -729,8 +733,8 @@ export class App extends Protocol { * @param options - Request options (timeout, etc.) * @returns Result containing the actual display mode that was set * - * @example Request fullscreen mode - * {@includeCode ./app.examples.ts#App_requestDisplayMode_fullscreen} + * @example Toggle display mode + * {@includeCode ./app.examples.ts#App_requestDisplayMode_toggle} * * @see {@link McpUiRequestDisplayModeRequest} for request structure * @see {@link McpUiHostContext} for checking availableDisplayModes diff --git a/src/server/index.examples.ts b/src/server/index.examples.ts index 9c5ae660..128279ce 100644 --- a/src/server/index.examples.ts +++ b/src/server/index.examples.ts @@ -86,11 +86,10 @@ function registerAppTool_basicUsage(server: McpServer) { } /** - * Example: Tool visibility - create app-only tools for UI actions. + * Example: Model-only visibility - tools visible to model but not callable by UI. */ -function registerAppTool_toolVisibility(server: McpServer) { - //#region registerAppTool_toolVisibility - // Main tool - visible to both model and app (default) +function registerAppTool_modelOnlyVisibility(server: McpServer) { + //#region registerAppTool_modelOnlyVisibility registerAppTool( server, "show-cart", @@ -99,7 +98,7 @@ function registerAppTool_toolVisibility(server: McpServer) { _meta: { ui: { resourceUri: "ui://shop/cart.html", - visibility: ["model", "app"], + visibility: ["model"], }, }, }, @@ -108,8 +107,14 @@ function registerAppTool_toolVisibility(server: McpServer) { return { content: [{ type: "text", text: JSON.stringify(cart) }] }; }, ); + //#endregion registerAppTool_modelOnlyVisibility +} - // App-only tool - hidden from the model, only callable by the UI +/** + * Example: App-only visibility - tools hidden from model, only callable by UI. + */ +function registerAppTool_appOnlyVisibility(server: McpServer) { + //#region registerAppTool_appOnlyVisibility registerAppTool( server, "update-quantity", @@ -128,7 +133,7 @@ function registerAppTool_toolVisibility(server: McpServer) { return { content: [{ type: "text", text: JSON.stringify(cart) }] }; }, ); - //#endregion registerAppTool_toolVisibility + //#endregion registerAppTool_appOnlyVisibility } /** diff --git a/src/server/index.ts b/src/server/index.ts index cbd9f1f1..826c8b34 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -117,8 +117,11 @@ export interface McpUiAppResourceConfig extends ResourceMetadata { * @example Basic usage * {@includeCode ./index.examples.ts#registerAppTool_basicUsage} * - * @example Tool visibility - create app-only tools for UI actions - * {@includeCode ./index.examples.ts#registerAppTool_toolVisibility} + * @example Tool visible to model but not callable by UI + * {@includeCode ./index.examples.ts#registerAppTool_modelOnlyVisibility} + * + * @example Tool hidden from model, only callable by UI + * {@includeCode ./index.examples.ts#registerAppTool_appOnlyVisibility} * * @see {@link registerAppResource} to register the HTML resource referenced by the tool */ diff --git a/src/styles.examples.ts b/src/styles.examples.ts index bf403d0e..8a922814 100644 --- a/src/styles.examples.ts +++ b/src/styles.examples.ts @@ -21,7 +21,7 @@ import { function getDocumentTheme_checkCurrent() { //#region getDocumentTheme_checkCurrent const theme = getDocumentTheme(); - const isDark = theme === "dark"; + document.body.classList.toggle("dark", theme === "dark"); //#endregion getDocumentTheme_checkCurrent } @@ -30,11 +30,20 @@ function getDocumentTheme_checkCurrent() { */ function applyDocumentTheme_fromHostContext(app: App) { //#region applyDocumentTheme_fromHostContext + // Apply when host context changes app.onhostcontextchanged = (params) => { if (params.theme) { applyDocumentTheme(params.theme); } }; + + // Apply initial theme after connecting + app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx?.theme) { + applyDocumentTheme(ctx.theme); + } + }); //#endregion applyDocumentTheme_fromHostContext } @@ -43,11 +52,23 @@ function applyDocumentTheme_fromHostContext(app: App) { */ function applyHostStyleVariables_fromHostContext(app: App) { //#region applyHostStyleVariables_fromHostContext + // Use CSS variables in your styles + document.body.style.background = "var(--color-background-primary)"; + + // Apply when host context changes app.onhostcontextchanged = (params) => { if (params.styles?.variables) { applyHostStyleVariables(params.styles.variables); } }; + + // Apply initial styles after connecting + app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx?.styles?.variables) { + applyHostStyleVariables(ctx.styles.variables); + } + }); //#endregion applyHostStyleVariables_fromHostContext } @@ -70,11 +91,20 @@ function applyHostStyleVariables_toElement(app: App) { */ function applyHostFonts_fromHostContext(app: App) { //#region applyHostFonts_fromHostContext + // Apply when host context changes app.onhostcontextchanged = (params) => { if (params.styles?.css?.fonts) { applyHostFonts(params.styles.css.fonts); } }; + + // Apply initial fonts after connecting + app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx?.styles?.css?.fonts) { + applyHostFonts(ctx.styles.css.fonts); + } + }); //#endregion applyHostFonts_fromHostContext } diff --git a/src/styles.ts b/src/styles.ts index 0727ced6..884564b2 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -77,6 +77,19 @@ export function applyDocumentTheme(theme: McpUiTheme): void { * @example Apply to a specific element * {@includeCode ./styles.examples.ts#applyHostStyleVariables_toElement} * + * @example Use host style variables in CSS + * ```css + * body { + * background-color: var(--color-background-primary); + * color: var(--color-text-primary); + * } + * + * .card { + * background-color: var(--color-background-secondary); + * border: 1px solid var(--color-border-primary); + * } + * ``` + * * @see {@link McpUiStyles} for the available CSS variables * @see {@link McpUiHostContext} for the full host context structure */ @@ -116,7 +129,7 @@ export function applyHostStyleVariables( * @example Use host fonts in CSS * ```css * body { - * font-family: "Anthropic Sans", sans-serif; + * font-family: var(--font-sans, system-ui, sans-serif); * } * ``` * diff --git a/tsconfig.json b/tsconfig.json index 85ec7892..08ff1103 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,6 @@ "skipLibCheck": true, "resolveJsonModule": true }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx", "docs/**/*.ts", "docs/**/*.tsx"], "exclude": ["node_modules", "dist", "examples/**/*.ts"] } diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 20163619..a109cf33 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -4,7 +4,11 @@ import { OptionDefaults } from "typedoc"; const config = { blockTags: [...OptionDefaults.blockTags, "@description"], intentionallyNotExported: ["AppOptions"], - projectDocuments: ["docs/quickstart.md"], + projectDocuments: [ + "docs/quickstart.md", + "docs/patterns.md", + "docs/migrate_from_openai_apps.md", + ], entryPoints: [ "src/server/index.ts", "src/app.ts",