From 58019a48ce5cd29b998f928f3befe4360df5ce63 Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Thu, 22 Jan 2026 17:30:43 -0600 Subject: [PATCH] Replace `{@includeCode}` with synced code fences Migrate JSDoc and markdown documentation from `{@includeCode ./file#region}` tags to inline code fences with `source="./file#region"` attributes. Code examples are now visible directly in source files while remaining type-checked via their `.examples.ts` source files. Add `scripts/sync-snippets.ts` to extract code from `//#region` blocks and sync it into labeled fences. Run with `npm run sync:snippets`. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 6 + AGENTS.md | 4 +- docs/patterns.md | 332 ++++++++++++++++++++- package.json | 3 +- scripts/sync-snippets.ts | 540 ++++++++++++++++++++++++++++++++++ src/app-bridge.ts | 284 ++++++++++++++++-- src/app.ts | 296 +++++++++++++++++-- src/message-transport.ts | 26 +- src/react/index.tsx | 14 +- src/react/useApp.tsx | 46 ++- src/react/useAutoResize.ts | 28 +- src/react/useDocumentTheme.ts | 25 +- src/react/useHostStyles.ts | 50 +++- src/server/index.ts | 130 +++++++- src/styles.ts | 88 +++++- 15 files changed, 1776 insertions(+), 96 deletions(-) create mode 100644 scripts/sync-snippets.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0b65a9f..c444585a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,12 @@ jobs: npm run generate:schemas git diff --exit-code src/generated/ || (echo "Generated schemas are out of date. Run 'npm run generate:schemas' and commit." && exit 1) + - name: Verify synced snippets are up-to-date + shell: bash + run: | + npm run sync:snippets + git diff --exit-code src/ docs/ || (echo "Synced snippets are out of date. Run 'npm run sync:snippets' and commit." && exit 1) + - run: npm test - run: npm run prettier diff --git a/AGENTS.md b/AGENTS.md index bcf105b9..ae8903a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,9 +80,9 @@ View (App) <--PostMessageTransport--> Host (AppBridge) <--MCP Client--> MCP Serv ## Documentation -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`). +JSDoc `@example` tags should pull type-checked code from companion `.examples.ts` files (e.g., `app.ts` → `app.examples.ts`). Use ` ```ts source="./file.examples.ts#regionName" ` fences referencing `//#region regionName` blocks, then run `npm run sync:snippets`. 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. +Standalone docs in `docs/` (listed in `typedoc.config.mjs` `projectDocuments`) can also have type-checked companion `.ts`/`.tsx` files using the same pattern. ## Full Examples diff --git a/docs/patterns.md b/docs/patterns.md index 662714bb..43d75193 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -10,7 +10,27 @@ This document covers common patterns and recipes for building MCP 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} + +```ts source="../src/server/index.examples.ts#registerAppTool_appOnlyVisibility" +registerAppTool( + server, + "update-quantity", + { + description: "Update item quantity in cart", + inputSchema: { itemId: z.string(), quantity: z.number() }, + _meta: { + ui: { + resourceUri: "ui://shop/cart.html", + visibility: ["app"], + }, + }, + }, + async ({ itemId, quantity }) => { + const cart = await updateCartItem(itemId, quantity); + return { content: [{ type: "text", text: JSON.stringify(cart) }] }; + }, +); +``` _See [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) for a full implementation of this pattern._ @@ -20,11 +40,121 @@ Some host platforms have size limits on tool call responses, so large files (PDF **Server-side**: Register an app-only tool that returns data in chunks with pagination metadata: -{@includeCode ./patterns.tsx#chunkedDataServer} + +```tsx source="./patterns.tsx#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, + }, + }; + }, +); +``` **Client-side**: Loop calling the tool until all chunks are received: -{@includeCode ./patterns.tsx#chunkedDataClient} + +```tsx source="./patterns.tsx#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`); +}); +``` _See [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) for a full implementation of this pattern._ @@ -34,7 +164,23 @@ _See [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/t **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} + +```ts source="../src/app.examples.ts#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", + }, + ], + }); +} +``` ## Matching host styling (CSS variables, theme, and fonts) @@ -46,11 +192,58 @@ Use the SDK's style helpers to apply host styling, then reference them in your C **Vanilla JS:** -{@includeCode ./patterns.tsx#hostStylingVanillaJs} + +```tsx source="./patterns.tsx#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); + } +}); +``` **React:** -{@includeCode ./patterns.tsx#hostStylingReact} + +```tsx source="./patterns.tsx#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

+
+ ); +} +``` _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._ @@ -58,11 +251,27 @@ _See [`examples/basic-server-vanillajs/`](https://github.com/modelcontextprotoco Toggle fullscreen mode by calling {@link app!App.requestDisplayMode `requestDisplayMode`}: -{@includeCode ../src/app.examples.ts#App_requestDisplayMode_toggle} + +```ts source="../src/app.examples.ts#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); +} +``` Listen for display mode changes via {@link app!App.onhostcontextchanged `onhostcontextchanged`} to update your UI: -{@includeCode ../src/app.examples.ts#App_onhostcontextchanged_respondToDisplayMode} + +```ts source="../src/app.examples.ts#App_onhostcontextchanged_respondToDisplayMode" +app.onhostcontextchanged = (params) => { + if (params.displayMode) { + const isFullscreen = params.displayMode === "fullscreen"; + document.body.classList.toggle("fullscreen", isFullscreen); + } +}; +``` _See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) for a full implementation of this pattern._ @@ -70,7 +279,22 @@ _See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext- 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} + +```ts source="../src/app.examples.ts#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: markdown }], +}); +``` _See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) for a full implementation of this pattern._ @@ -78,7 +302,24 @@ _See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/t 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} + +```ts source="../src/app.examples.ts#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" }], +}); +``` _See [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server) for a full implementation of this pattern._ @@ -88,11 +329,31 @@ To persist view state across conversation reloads (e.g., current page in a PDF v **Server-side**: Tool handler generates a unique `viewUUID` and returns it in `CallToolResult._meta.viewUUID`: -{@includeCode ./patterns.tsx#persistDataServer} + +```tsx source="./patterns.tsx#persistDataServer" +// In your tool callback, include viewUUID in the result metadata. +return { + content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }], + structuredContent: { url, title, pageCount, initialPage: 1 }, + _meta: { + viewUUID: randomUUID(), + }, +}; +``` **Client-side**: Receive the UUID in {@link app!App.ontoolresult `ontoolresult`} and use it as the storage key: -{@includeCode ./patterns.tsx#persistData} + +```tsx source="./patterns.tsx#persistData" +// In your tool callback, include viewUUID in the result metadata. +return { + content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }], + structuredContent: { url, title, pageCount, initialPage: 1 }, + _meta: { + viewUUID: randomUUID(), + }, +}; +``` _See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) for a full implementation of this pattern._ @@ -100,7 +361,27 @@ _See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/t Views 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 view isn't visible: -{@includeCode ./patterns.tsx#visibilityBasedPause} + +```tsx source="./patterns.tsx#visibilityBasedPause" +// Use IntersectionObserver to pause when view 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 view +app.onteardown = async () => { + observer.disconnect(); + animation.pause(); + return {}; +}; +``` _See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) for a full implementation of this pattern._ @@ -108,7 +389,30 @@ _See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext- 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} + +```ts source="../src/app.examples.ts#App_ontoolinputpartial_progressiveRendering" +let toolInputs: Record | null = null; +let toolInputsPartial: Record | null = null; + +app.ontoolinputpartial = (params) => { + 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 + } +} +``` > [!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. diff --git a/package.json b/package.json index 7b62d7a6..cd2d6cf4 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "postinstall": "node scripts/setup-bun.mjs || echo 'setup-bun.mjs failed or not available'", "start": "npm run examples:dev", "generate:schemas": "tsx scripts/generate-schemas.ts && prettier --write \"src/generated/**/*\"", - "build": "npm run generate:schemas && node scripts/run-bun.mjs build.bun.ts", + "sync:snippets": "bun scripts/sync-snippets.ts", + "build": "npm run generate:schemas && npm run sync:snippets && node scripts/run-bun.mjs build.bun.ts", "prepack": "npm run build", "build:all": "npm run examples:build", "test": "bun test src", diff --git a/scripts/sync-snippets.ts b/scripts/sync-snippets.ts new file mode 100644 index 00000000..3933727a --- /dev/null +++ b/scripts/sync-snippets.ts @@ -0,0 +1,540 @@ +/** + * Code Snippet Sync Script + * + * This script syncs code snippets from `.examples.ts/.examples.tsx` files + * into JSDoc comments containing labeled code fences. + * + * The script replaces the content inside code fences that have a path#region + * reference in their info string. + * + * ## Code Fence Format + * + * ``````typescript + * ```ts source="./path.examples.ts#regionName" + * // code is synced here + * ``` + * `````` + * + * Optionally, a display filename can be shown before the source reference: + * + * ``````typescript + * ```ts my-app.ts source="./path.examples.ts#regionName" + * // code is synced here + * ``` + * `````` + * + * ## Region Format (in .examples.ts files) + * + * ``````typescript + * //#region regionName + * // code here + * //#endregion regionName + * `````` + * + * Run: npm run sync:snippets + */ + +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const PROJECT_ROOT = join(__dirname, ".."); +const SRC_DIR = join(PROJECT_ROOT, "src"); +const DOCS_DIR = join(PROJECT_ROOT, "docs"); + +/** Processing mode based on file type */ +type FileMode = "jsdoc" | "markdown"; + +/** + * Represents a labeled code fence found in a source file. + */ +interface LabeledCodeFence { + /** Optional display filename (e.g., "my-app.ts") */ + displayName?: string; + /** Relative path to the example file (e.g., "./app.examples.ts") */ + examplePath: string; + /** Region name (e.g., "App_basicUsage") */ + regionName: string; + /** Language from the code fence (ts or tsx) */ + language: "ts" | "tsx"; + /** Character index of the opening fence line start */ + openingFenceStart: number; + /** Character index after the opening fence line (after newline) */ + openingFenceEnd: number; + /** Character index of the closing fence line start */ + closingFenceStart: number; + /** The JSDoc line prefix extracted from context (e.g., " * ") */ + linePrefix: string; +} + +/** + * Represents extracted region content from an example file. + */ +interface RegionContent { + /** The dedented code content */ + code: string; + /** Language for code fence (ts or tsx) */ + language: "ts" | "tsx"; +} + +/** + * Cache for example file regions to avoid re-reading files. + * Key: absolute example file path + * Value: Map + */ +type RegionCache = Map>; + +/** + * Processing result for a source file. + */ +interface FileProcessingResult { + filePath: string; + modified: boolean; + snippetsProcessed: number; + errors: string[]; +} + +// JSDoc patterns - for code fences inside JSDoc comments with " * " prefix +// Matches: ``` [displayName] source="#" +// Example: " * ```ts my-app.ts source="./app.examples.ts#App_basicUsage"" +// Example: " * ```ts source="./app.examples.ts#App_basicUsage"" +const JSDOC_LABELED_FENCE_PATTERN = + /^(\s*\*\s*)```(ts|tsx)(?:\s+(\S+))?\s+source="([^"#]+)#([^"]+)"/; +const JSDOC_CLOSING_FENCE_PATTERN = /^(\s*\*\s*)```\s*$/; + +// Markdown patterns - for plain code fences in markdown files (no prefix) +// Matches: ``` [displayName] source="#" +// Example: ```tsx source="./patterns.tsx#chunkedDataServer" +const MARKDOWN_LABELED_FENCE_PATTERN = + /^```(ts|tsx)(?:\s+(\S+))?\s+source="([^"#]+)#([^"]+)"/; +const MARKDOWN_CLOSING_FENCE_PATTERN = /^```\s*$/; + +/** + * Find all labeled code fences in a source file. + * @param content The file content + * @param filePath The file path (for error messages) + * @param mode The processing mode (jsdoc or markdown) + * @returns Array of labeled code fence references + */ +function findLabeledCodeFences( + content: string, + filePath: string, + mode: FileMode, +): LabeledCodeFence[] { + const results: LabeledCodeFence[] = []; + const lines = content.split("\n"); + let charIndex = 0; + + // Select patterns based on mode + const openPattern = + mode === "jsdoc" + ? JSDOC_LABELED_FENCE_PATTERN + : MARKDOWN_LABELED_FENCE_PATTERN; + const closePattern = + mode === "jsdoc" + ? JSDOC_CLOSING_FENCE_PATTERN + : MARKDOWN_CLOSING_FENCE_PATTERN; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const openMatch = line.match(openPattern); + + if (openMatch) { + let linePrefix: string; + let language: string; + let displayName: string | undefined; + let examplePath: string; + let regionName: string; + + if (mode === "jsdoc") { + // JSDoc: group 1=prefix, 2=lang, 3=displayName, 4=path, 5=region + [, linePrefix, language, displayName, examplePath, regionName] = + openMatch; + } else { + // Markdown: group 1=lang, 2=displayName, 3=path, 4=region (no prefix) + [, language, displayName, examplePath, regionName] = openMatch; + linePrefix = ""; + } + + const openingFenceStart = charIndex; + const openingFenceEnd = charIndex + line.length + 1; // +1 for newline + + // Find closing fence + let closingFenceStart = -1; + let searchIndex = openingFenceEnd; + + for (let j = i + 1; j < lines.length; j++) { + const closeLine = lines[j]; + if (closePattern.test(closeLine)) { + closingFenceStart = searchIndex; + break; + } + searchIndex += closeLine.length + 1; + } + + if (closingFenceStart === -1) { + throw new Error( + `${filePath}: No closing fence for ${examplePath}#${regionName}`, + ); + } + + results.push({ + displayName, + examplePath, + regionName, + language: language as "ts" | "tsx", + openingFenceStart, + openingFenceEnd, + closingFenceStart, + linePrefix, + }); + } + + charIndex += line.length + 1; + } + + return results; +} + +/** + * Dedent content by removing a base indentation prefix from each line. + * @param content The content to dedent + * @param baseIndent The indentation to remove + * @returns The dedented content + */ +function dedent(content: string, baseIndent: string): string { + if (!baseIndent) return content; + + const lines = content.split("\n"); + const dedentedLines = lines.map((line) => { + // Preserve empty lines as-is + if (line.trim() === "") return ""; + // Remove the base indentation if present + if (line.startsWith(baseIndent)) { + return line.slice(baseIndent.length); + } + // Line has less indentation than base - keep as-is + return line; + }); + + // Trim trailing empty lines + while ( + dedentedLines.length > 0 && + dedentedLines[dedentedLines.length - 1] === "" + ) { + dedentedLines.pop(); + } + + return dedentedLines.join("\n"); +} + +/** + * Extract a region from an example file. + * @param exampleContent The content of the example file + * @param regionName The region name to extract + * @param examplePath The example file path (for error messages) + * @returns The dedented region content + */ +function extractRegion( + exampleContent: string, + regionName: string, + examplePath: string, +): string { + const regionStart = `//#region ${regionName}`; + const regionEnd = `//#endregion ${regionName}`; + + const startIndex = exampleContent.indexOf(regionStart); + if (startIndex === -1) { + throw new Error(`Region "${regionName}" not found in ${examplePath}`); + } + + const endIndex = exampleContent.indexOf(regionEnd, startIndex); + if (endIndex === -1) { + throw new Error( + `Region end marker for "${regionName}" not found in ${examplePath}`, + ); + } + + // Get content after the region start line + const afterStart = exampleContent.indexOf("\n", startIndex); + if (afterStart === -1 || afterStart >= endIndex) { + return ""; // Empty region + } + + // Extract the raw content + const rawContent = exampleContent.slice(afterStart + 1, endIndex); + + // Determine base indentation from the //#region line + let lineStart = exampleContent.lastIndexOf("\n", startIndex); + lineStart = lineStart === -1 ? 0 : lineStart + 1; + const regionLine = exampleContent.slice(lineStart, startIndex); + + // The base indent is the whitespace before //#region + const baseIndent = regionLine; + + return dedent(rawContent, baseIndent); +} + +/** + * Get or load a region from the cache. + * @param sourceFilePath The source file requesting the region + * @param examplePath The relative path to the example file + * @param regionName The region name to extract + * @param cache The region cache + * @returns The region content + */ +function getOrLoadRegion( + sourceFilePath: string, + examplePath: string, + regionName: string, + cache: RegionCache, +): RegionContent { + // Resolve the example path relative to the source file + const sourceDir = dirname(sourceFilePath); + const absoluteExamplePath = resolve(sourceDir, examplePath); + + // Check cache first + let fileCache = cache.get(absoluteExamplePath); + if (fileCache) { + const cached = fileCache.get(regionName); + if (cached) { + return cached; + } + } + + // Load the example file + let exampleContent: string; + try { + exampleContent = readFileSync(absoluteExamplePath, "utf-8"); + } catch { + throw new Error(`Example file not found: ${absoluteExamplePath}`); + } + + // Initialize file cache if needed + if (!fileCache) { + fileCache = new Map(); + cache.set(absoluteExamplePath, fileCache); + } + + // Determine language from file extension + const language: "ts" | "tsx" = absoluteExamplePath.endsWith(".tsx") + ? "tsx" + : "ts"; + + // Extract the region + const code = extractRegion(exampleContent, regionName, examplePath); + + const regionContent: RegionContent = { code, language }; + fileCache.set(regionName, regionContent); + + return regionContent; +} + +/** + * Format code lines for insertion into a JSDoc comment. + * @param code The code to format + * @param linePrefix The JSDoc line prefix (e.g., " * ") + * @returns The formatted code with JSDoc prefixes + */ +function formatCodeLines(code: string, linePrefix: string): string { + const lines = code.split("\n"); + return lines + .map((line) => + line === "" ? linePrefix.trimEnd() : `${linePrefix}${line}`, + ) + .join("\n"); +} + +/** + * Process a single source file to sync snippets. + * @param filePath The source file path + * @param cache The region cache + * @param mode The processing mode (jsdoc or markdown) + * @returns The processing result + */ +function processFile( + filePath: string, + cache: RegionCache, + mode: FileMode, +): FileProcessingResult { + const result: FileProcessingResult = { + filePath, + modified: false, + snippetsProcessed: 0, + errors: [], + }; + + let content: string; + try { + content = readFileSync(filePath, "utf-8"); + } catch (err) { + result.errors.push(`Failed to read file: ${err}`); + return result; + } + + let fences: LabeledCodeFence[]; + try { + fences = findLabeledCodeFences(content, filePath, mode); + } catch (err) { + result.errors.push(err instanceof Error ? err.message : String(err)); + return result; + } + + if (fences.length === 0) { + return result; + } + + const originalContent = content; + + // Process fences in reverse order to preserve positions + for (let i = fences.length - 1; i >= 0; i--) { + const fence = fences[i]; + + try { + const regionContent = getOrLoadRegion( + filePath, + fence.examplePath, + fence.regionName, + cache, + ); + + const formattedCode = formatCodeLines( + regionContent.code, + fence.linePrefix, + ); + + // Replace content between opening fence end and closing fence start + content = + content.slice(0, fence.openingFenceEnd) + + formattedCode + + "\n" + + content.slice(fence.closingFenceStart); + + result.snippetsProcessed++; + } catch (err) { + result.errors.push( + `${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if ( + result.snippetsProcessed > 0 && + result.errors.length === 0 && + content !== originalContent + ) { + writeFileSync(filePath, content); + result.modified = true; + } + + return result; +} + +/** + * Find all TypeScript source files in a directory, excluding examples, tests, and generated files. + * @param dir The directory to search + * @returns Array of absolute file paths + */ +function findSourceFiles(dir: string): string[] { + const files: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); + + for (const entry of entries) { + if (!entry.isFile()) continue; + + const name = entry.name; + + // Only process .ts and .tsx files + if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue; + + // Exclude example files, test files + if (name.endsWith(".examples.ts") || name.endsWith(".examples.tsx")) + continue; + if (name.endsWith(".test.ts")) continue; + + // Get the relative path from the parent directory + const parentPath = entry.parentPath; + + // Exclude generated directory + if (parentPath.includes("/generated") || parentPath.includes("\\generated")) + continue; + + const fullPath = join(parentPath, name); + files.push(fullPath); + } + + return files; +} + +/** + * Find all markdown files in a directory. + * @param dir The directory to search + * @returns Array of absolute file paths + */ +function findMarkdownFiles(dir: string): string[] { + const files: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); + + for (const entry of entries) { + if (!entry.isFile()) continue; + + // Only process .md files + if (!entry.name.endsWith(".md")) continue; + + const fullPath = join(entry.parentPath, entry.name); + files.push(fullPath); + } + + return files; +} + +async function main() { + console.log("šŸ”§ Syncing code snippets from example files...\n"); + + const cache: RegionCache = new Map(); + const results: FileProcessingResult[] = []; + + // Process TypeScript source files (JSDoc mode) + const sourceFiles = findSourceFiles(SRC_DIR); + for (const filePath of sourceFiles) { + const result = processFile(filePath, cache, "jsdoc"); + results.push(result); + } + + // Process markdown documentation files + const markdownFiles = findMarkdownFiles(DOCS_DIR); + for (const filePath of markdownFiles) { + const result = processFile(filePath, cache, "markdown"); + results.push(result); + } + + // Report results + const modified = results.filter((r) => r.modified); + const errors = results.flatMap((r) => r.errors); + + if (modified.length > 0) { + console.log(`āœ… Modified ${modified.length} file(s):`); + for (const r of modified) { + console.log(` ${r.filePath} (${r.snippetsProcessed} snippet(s))`); + } + } else { + console.log("āœ… No files needed modification"); + } + + if (errors.length > 0) { + console.error("\nāŒ Errors:"); + for (const error of errors) { + console.error(` ${error}`); + } + process.exit(1); + } + + console.log("\nšŸŽ‰ Snippet sync complete!"); +} + +main().catch((error) => { + console.error("āŒ Snippet sync failed:", error); + process.exit(1); +}); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 0baab5d8..d8f7dee1 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -215,7 +215,36 @@ type RequestHandlerExtra = Parameters< * 5. **Teardown**: Call {@link teardownResource `teardownResource`} before unmounting iframe * * @example Basic usage - * {@includeCode ./app-bridge.examples.ts#AppBridge_basicUsage} + * ```ts source="./app-bridge.examples.ts#AppBridge_basicUsage" + * // Create MCP client for the server + * const client = new Client({ + * name: "MyHost", + * version: "1.0.0", + * }); + * await client.connect(serverTransport); + * + * // Create bridge for the View + * const bridge = new AppBridge( + * client, + * { name: "MyHost", version: "1.0.0" }, + * { openLinks: {}, serverTools: {}, logging: {} }, + * ); + * + * // Set up iframe and connect + * const iframe = document.getElementById("app") as HTMLIFrameElement; + * const transport = new PostMessageTransport( + * iframe.contentWindow!, + * iframe.contentWindow!, + * ); + * + * bridge.oninitialized = () => { + * console.log("View initialized"); + * // Now safe to send tool input + * bridge.sendToolInput({ arguments: { location: "NYC" } }); + * }; + * + * await bridge.connect(transport); + * ``` */ export class AppBridge extends Protocol< AppRequest, @@ -238,10 +267,26 @@ export class AppBridge extends Protocol< * @param options - Configuration options (inherited from Protocol) * * @example With MCP client (automatic forwarding) - * {@includeCode ./app-bridge.examples.ts#AppBridge_constructor_withMcpClient} + * ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient" + * const bridge = new AppBridge( + * mcpClient, + * { name: "MyHost", version: "1.0.0" }, + * { openLinks: {}, serverTools: {}, logging: {} }, + * ); + * ``` * * @example Without MCP client (manual handlers) - * {@includeCode ./app-bridge.examples.ts#AppBridge_constructor_withoutMcpClient} + * ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withoutMcpClient" + * const bridge = new AppBridge( + * null, + * { name: "MyHost", version: "1.0.0" }, + * { openLinks: {}, serverTools: {}, logging: {} }, + * ); + * bridge.oncalltool = async (params, extra) => { + * // Handle tool calls manually + * return { content: [] }; + * }; + * ``` */ constructor( private _client: Client | null, @@ -280,7 +325,14 @@ export class AppBridge extends Protocol< * @returns view capabilities, or `undefined` if not yet initialized * * @example Check view capabilities after initialization - * {@includeCode ./app-bridge.examples.ts#AppBridge_getAppCapabilities_checkAfterInit} + * ```ts source="./app-bridge.examples.ts#AppBridge_getAppCapabilities_checkAfterInit" + * bridge.oninitialized = () => { + * const caps = bridge.getAppCapabilities(); + * if (caps?.tools) { + * console.log("View provides tools"); + * } + * }; + * ``` * * @see {@link McpUiAppCapabilities `McpUiAppCapabilities`} for the capabilities structure */ @@ -297,7 +349,14 @@ export class AppBridge extends Protocol< * @returns view implementation info, or `undefined` if not yet initialized * * @example Log view information after initialization - * {@includeCode ./app-bridge.examples.ts#AppBridge_getAppVersion_logAfterInit} + * ```ts source="./app-bridge.examples.ts#AppBridge_getAppVersion_logAfterInit" + * bridge.oninitialized = () => { + * const appInfo = bridge.getAppVersion(); + * if (appInfo) { + * console.log(`View: ${appInfo.name} v${appInfo.version}`); + * } + * }; + * ``` */ getAppVersion(): Implementation | undefined { return this._appInfo; @@ -317,7 +376,11 @@ export class AppBridge extends Protocol< * @param extra - Request metadata (abort signal, session info) * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onping_handleRequest} + * ```ts source="./app-bridge.examples.ts#AppBridge_onping_handleRequest" + * bridge.onping = (params, extra) => { + * console.log("Received ping from view"); + * }; + * ``` */ onping?: (params: PingRequest["params"], extra: RequestHandlerExtra) => void; @@ -332,7 +395,16 @@ export class AppBridge extends Protocol< * host container dimension changes, use {@link setHostContext `setHostContext`}. * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onsizechange_handleResize} + * ```ts source="./app-bridge.examples.ts#AppBridge_onsizechange_handleResize" + * bridge.onsizechange = ({ width, height }) => { + * if (width != null) { + * iframe.style.width = `${width}px`; + * } + * if (height != null) { + * iframe.style.height = `${height}px`; + * } + * }; + * ``` * * @see {@link McpUiSizeChangedNotification `McpUiSizeChangedNotification`} for the notification type * @see {@link app!App.sendSizeChanged `App.sendSizeChanged`} - the View method that sends these notifications @@ -390,7 +462,12 @@ export class AppBridge extends Protocol< * initialization handshake and is ready to receive tool input and other data. * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_oninitialized_sendToolInput} + * ```ts source="./app-bridge.examples.ts#AppBridge_oninitialized_sendToolInput" + * bridge.oninitialized = () => { + * console.log("View ready"); + * bridge.sendToolInput({ arguments: toolArgs }); + * }; + * ``` * * @see {@link McpUiInitializedNotification `McpUiInitializedNotification`} for the notification type * @see {@link sendToolInput `sendToolInput`} for sending tool arguments to the View @@ -422,7 +499,17 @@ export class AppBridge extends Protocol< * - Returns: `Promise` with optional `isError` flag * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onmessage_logMessage} + * ```ts source="./app-bridge.examples.ts#AppBridge_onmessage_logMessage" + * bridge.onmessage = async ({ role, content }, extra) => { + * try { + * await chatManager.addMessage({ role, content, source: "app" }); + * return {}; // Success + * } catch (error) { + * console.error("Failed to add message:", error); + * return { isError: true }; + * } + * }; + * ``` * * @see {@link McpUiMessageRequest `McpUiMessageRequest`} for the request type * @see {@link McpUiMessageResult `McpUiMessageResult`} for the result type @@ -460,7 +547,26 @@ export class AppBridge extends Protocol< * - Returns: `Promise` with optional `isError` flag * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onopenlink_handleRequest} + * ```ts source="./app-bridge.examples.ts#AppBridge_onopenlink_handleRequest" + * bridge.onopenlink = async ({ url }, extra) => { + * if (!isAllowedDomain(url)) { + * console.warn("Blocked external link:", url); + * return { isError: true }; + * } + * + * const confirmed = await showDialog({ + * message: `Open external link?\n${url}`, + * buttons: ["Open", "Cancel"], + * }); + * + * if (confirmed) { + * window.open(url, "_blank", "noopener,noreferrer"); + * return {}; + * } + * + * return { isError: true }; + * }; + * ``` * * @see {@link McpUiOpenLinkRequest `McpUiOpenLinkRequest`} for the request type * @see {@link McpUiOpenLinkResult `McpUiOpenLinkResult`} for the result type @@ -499,7 +605,14 @@ export class AppBridge extends Protocol< * - Returns: `Promise` with the actual mode set * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onrequestdisplaymode_handleRequest} + * ```ts source="./app-bridge.examples.ts#AppBridge_onrequestdisplaymode_handleRequest" + * bridge.onrequestdisplaymode = async ({ mode }, extra) => { + * if (availableDisplayModes.includes(mode)) { + * currentDisplayMode = mode; + * } + * return { mode: currentDisplayMode }; + * }; + * ``` * * @see {@link McpUiRequestDisplayModeRequest `McpUiRequestDisplayModeRequest`} for the request type * @see {@link McpUiRequestDisplayModeResult `McpUiRequestDisplayModeResult`} for the result type @@ -535,7 +648,14 @@ export class AppBridge extends Protocol< * - `params.data` - Log message and optional structured data * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onloggingmessage_handleLog} + * ```ts source="./app-bridge.examples.ts#AppBridge_onloggingmessage_handleLog" + * bridge.onloggingmessage = ({ level, logger, data }) => { + * console[level === "error" ? "error" : "log"]( + * `[${logger ?? "View"}] ${level.toUpperCase()}:`, + * data, + * ); + * }; + * ``` */ set onloggingmessage( callback: (params: LoggingMessageNotification["params"]) => void, @@ -561,7 +681,16 @@ export class AppBridge extends Protocol< * update received. * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onupdatemodelcontext_storeContext} + * ```ts source="./app-bridge.examples.ts#AppBridge_onupdatemodelcontext_storeContext" + * bridge.onupdatemodelcontext = async ( + * { content, structuredContent }, + * extra, + * ) => { + * // Store the context snapshot for inclusion in the next model request + * modelContextManager.update({ content, structuredContent }); + * return {}; + * }; + * ``` * * @see {@link McpUiUpdateModelContextRequest `McpUiUpdateModelContextRequest`} for the request type */ @@ -592,7 +721,15 @@ export class AppBridge extends Protocol< * - `extra` - Request metadata (abort signal, session info) * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_oncalltool_forwardToServer} + * ```ts source="./app-bridge.examples.ts#AppBridge_oncalltool_forwardToServer" + * bridge.oncalltool = async (params, extra) => { + * return mcpClient.request( + * { method: "tools/call", params }, + * CallToolResultSchema, + * { signal: extra.signal }, + * ); + * }; + * ``` * * @see `CallToolRequest` from @modelcontextprotocol/sdk for the request type * @see `CallToolResult` from @modelcontextprotocol/sdk for the result type @@ -647,7 +784,15 @@ export class AppBridge extends Protocol< * - `extra` - Request metadata (abort signal, session info) * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onlistresources_returnResources} + * ```ts source="./app-bridge.examples.ts#AppBridge_onlistresources_returnResources" + * bridge.onlistresources = async (params, extra) => { + * return mcpClient.request( + * { method: "resources/list", params }, + * ListResourcesResultSchema, + * { signal: extra.signal }, + * ); + * }; + * ``` * * @see `ListResourcesRequest` from @modelcontextprotocol/sdk for the request type * @see `ListResourcesResult` from @modelcontextprotocol/sdk for the result type @@ -719,7 +864,15 @@ export class AppBridge extends Protocol< * - `extra` - Request metadata (abort signal, session info) * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onreadresource_returnResource} + * ```ts source="./app-bridge.examples.ts#AppBridge_onreadresource_returnResource" + * bridge.onreadresource = async (params, extra) => { + * return mcpClient.request( + * { method: "resources/read", params }, + * ReadResourceResultSchema, + * { signal: extra.signal }, + * ); + * }; + * ``` * * @see `ReadResourceRequest` from @modelcontextprotocol/sdk for the request type * @see `ReadResourceResult` from @modelcontextprotocol/sdk for the result type @@ -779,7 +932,15 @@ export class AppBridge extends Protocol< * - `extra` - Request metadata (abort signal, session info) * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_onlistprompts_returnPrompts} + * ```ts source="./app-bridge.examples.ts#AppBridge_onlistprompts_returnPrompts" + * bridge.onlistprompts = async (params, extra) => { + * return mcpClient.request( + * { method: "prompts/list", params }, + * ListPromptsResultSchema, + * { signal: extra.signal }, + * ); + * }; + * ``` * * @see `ListPromptsRequest` from @modelcontextprotocol/sdk for the request type * @see `ListPromptsResult` from @modelcontextprotocol/sdk for the result type @@ -915,10 +1076,17 @@ export class AppBridge extends Protocol< * @param hostContext - The complete new host context state * * @example Update theme when user toggles dark mode - * {@includeCode ./app-bridge.examples.ts#AppBridge_setHostContext_updateTheme} + * ```ts source="./app-bridge.examples.ts#AppBridge_setHostContext_updateTheme" + * bridge.setHostContext({ theme: "dark" }); + * ``` * * @example Update multiple context fields - * {@includeCode ./app-bridge.examples.ts#AppBridge_setHostContext_updateMultiple} + * ```ts source="./app-bridge.examples.ts#AppBridge_setHostContext_updateMultiple" + * bridge.setHostContext({ + * theme: "dark", + * containerDimensions: { maxHeight: 600, width: 800 }, + * }); + * ``` * * @see {@link McpUiHostContext `McpUiHostContext`} for the context structure * @see {@link McpUiHostContextChangedNotification `McpUiHostContextChangedNotification`} for the notification type @@ -971,7 +1139,13 @@ export class AppBridge extends Protocol< * @param params - Complete tool call arguments * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_sendToolInput_afterInit} + * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolInput_afterInit" + * bridge.oninitialized = () => { + * bridge.sendToolInput({ + * arguments: { location: "New York", units: "metric" }, + * }); + * }; + * ``` * * @see {@link McpUiToolInputNotification `McpUiToolInputNotification`} for the notification type * @see {@link oninitialized `oninitialized`} for the initialization callback @@ -998,7 +1172,17 @@ export class AppBridge extends Protocol< * @param params - Partial tool call arguments (may be incomplete) * * @example Stream partial arguments as they arrive - * {@includeCode ./app-bridge.examples.ts#AppBridge_sendToolInputPartial_streaming} + * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolInputPartial_streaming" + * // As streaming progresses... + * bridge.sendToolInputPartial({ arguments: { loc: "N" } }); + * bridge.sendToolInputPartial({ arguments: { location: "New" } }); + * bridge.sendToolInputPartial({ arguments: { location: "New York" } }); + * + * // When complete, send final input + * bridge.sendToolInput({ + * arguments: { location: "New York", units: "metric" }, + * }); + * ``` * * @see {@link McpUiToolInputPartialNotification `McpUiToolInputPartialNotification`} for the notification type * @see {@link sendToolInput `sendToolInput`} for sending complete arguments @@ -1021,7 +1205,13 @@ export class AppBridge extends Protocol< * @param params - Standard MCP tool execution result * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_sendToolResult_afterExecution} + * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolResult_afterExecution" + * const result = await mcpClient.request( + * { method: "tools/call", params: { name: "get_weather", arguments: args } }, + * CallToolResultSchema, + * ); + * bridge.sendToolResult(result); + * ``` * * @see {@link McpUiToolResultNotification `McpUiToolResultNotification`} for the notification type * @see {@link sendToolInput `sendToolInput`} for sending tool arguments before results @@ -1045,10 +1235,19 @@ export class AppBridge extends Protocol< * - `reason`: Human-readable explanation for why the tool was cancelled * * @example User-initiated cancellation - * {@includeCode ./app-bridge.examples.ts#AppBridge_sendToolCancelled_userInitiated} + * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolCancelled_userInitiated" + * // User clicked "Cancel" button + * bridge.sendToolCancelled({ reason: "User cancelled the operation" }); + * ``` * * @example System-level cancellation - * {@includeCode ./app-bridge.examples.ts#AppBridge_sendToolCancelled_systemLevel} + * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolCancelled_systemLevel" + * // Sampling error or timeout + * bridge.sendToolCancelled({ reason: "Request timeout after 30 seconds" }); + * + * // Classifier intervention + * bridge.sendToolCancelled({ reason: "Content policy violation detected" }); + * ``` * * @see {@link McpUiToolCancelledNotification `McpUiToolCancelledNotification`} for the notification type * @see {@link sendToolResult `sendToolResult`} for sending successful results @@ -1099,7 +1298,15 @@ export class AppBridge extends Protocol< * @returns Promise resolving when view confirms readiness for teardown * * @example - * {@includeCode ./app-bridge.examples.ts#AppBridge_teardownResource_gracefulShutdown} + * ```ts source="./app-bridge.examples.ts#AppBridge_teardownResource_gracefulShutdown" + * try { + * await bridge.teardownResource({}); + * // View is ready, safe to unmount iframe + * iframe.remove(); + * } catch (error) { + * console.error("Teardown failed:", error); + * } + * ``` */ teardownResource( params: McpUiResourceTeardownRequest["params"], @@ -1144,10 +1351,33 @@ export class AppBridge extends Protocol< * before calling `bridge.connect()`. * * @example With MCP client (automatic forwarding) - * {@includeCode ./app-bridge.examples.ts#AppBridge_connect_withMcpClient} + * ```ts source="./app-bridge.examples.ts#AppBridge_connect_withMcpClient" + * const bridge = new AppBridge(mcpClient, hostInfo, capabilities); + * const transport = new PostMessageTransport( + * iframe.contentWindow!, + * iframe.contentWindow!, + * ); + * + * bridge.oninitialized = () => { + * console.log("View ready"); + * bridge.sendToolInput({ arguments: toolArgs }); + * }; + * + * await bridge.connect(transport); + * ``` * * @example Without MCP client (manual handlers) - * {@includeCode ./app-bridge.examples.ts#AppBridge_connect_withoutMcpClient} + * ```ts source="./app-bridge.examples.ts#AppBridge_connect_withoutMcpClient" + * const bridge = new AppBridge(null, hostInfo, capabilities); + * + * // Register handlers manually + * bridge.oncalltool = async (params, extra) => { + * // Custom tool call handling + * return { content: [] }; + * }; + * + * await bridge.connect(transport); + * ``` */ async connect(transport: Transport) { if (this._client) { diff --git a/src/app.ts b/src/app.ts index 11d48c12..06788001 100644 --- a/src/app.ts +++ b/src/app.ts @@ -72,10 +72,27 @@ export { * with the `_meta.ui.resourceUri` format instead. * * @example How MCP servers use this key (server-side, not in Apps) - * {@includeCode ./app.examples.ts#RESOURCE_URI_META_KEY_serverSide} + * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_serverSide" + * server.registerTool( + * "weather", + * { + * description: "Get weather forecast", + * _meta: { + * [RESOURCE_URI_META_KEY]: "ui://weather/forecast", + * }, + * }, + * handler, + * ); + * ``` * * @example How hosts check for this metadata (host-side) - * {@includeCode ./app.examples.ts#RESOURCE_URI_META_KEY_hostSide} + * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_hostSide" + * // Check tool definition metadata (from tools/list response): + * const uiUri = tool._meta?.[RESOURCE_URI_META_KEY]; + * if (typeof uiUri === "string" && uiUri.startsWith("ui://")) { + * // Fetch the resource and display the UI + * } + * ``` */ export const RESOURCE_URI_META_KEY = "ui/resourceUri"; @@ -155,7 +172,19 @@ type RequestHandlerExtra = Parameters< * Both patterns work; use whichever fits your coding style better. * * @example Basic usage with PostMessageTransport - * {@includeCode ./app.examples.ts#App_basicUsage} + * ```ts source="./app.examples.ts#App_basicUsage" + * const app = new App( + * { name: "WeatherApp", version: "1.0.0" }, + * {}, // capabilities + * ); + * + * // Register handlers before connecting to ensure no notifications are missed + * app.ontoolinput = (params) => { + * console.log("Tool arguments:", params.arguments); + * }; + * + * await app.connect(); + * ``` */ export class App extends Protocol { private _hostCapabilities?: McpUiHostCapabilities; @@ -170,7 +199,13 @@ export class App extends Protocol { * @param options - Configuration options including `autoResize` behavior * * @example - * {@includeCode ./app.examples.ts#App_constructor_basic} + * ```ts source="./app.examples.ts#App_constructor_basic" + * const app = new App( + * { name: "MyApp", version: "1.0.0" }, + * { tools: { listChanged: true } }, // capabilities + * { autoResize: true }, // options + * ); + * ``` */ constructor( private _appInfo: Implementation, @@ -199,7 +234,12 @@ export class App extends Protocol { * @returns Host capabilities, or `undefined` if not yet connected * * @example Check host capabilities after connection - * {@includeCode ./app.examples.ts#App_getHostCapabilities_checkAfterConnection} + * ```ts source="./app.examples.ts#App_getHostCapabilities_checkAfterConnection" + * await app.connect(); + * if (app.getHostCapabilities()?.serverTools) { + * console.log("Host supports server tool calls"); + * } + * ``` * * @see {@link connect `connect`} for the initialization handshake * @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure @@ -218,7 +258,11 @@ export class App extends Protocol { * @returns Host implementation info, or `undefined` if not yet connected * * @example Log host information after connection - * {@includeCode ./app.examples.ts#App_getHostVersion_logAfterConnection} + * ```ts source="./app.examples.ts#App_getHostVersion_logAfterConnection" + * await app.connect(transport); + * const { name, version } = app.getHostVersion() ?? {}; + * console.log(`Connected to ${name} v${version}`); + * ``` * * @see {@link connect `connect`} for the initialization handshake */ @@ -239,7 +283,16 @@ export class App extends Protocol { * @returns Host context, or `undefined` if not yet connected * * @example Access host context after connection - * {@includeCode ./app.examples.ts#App_getHostContext_accessAfterConnection} + * ```ts source="./app.examples.ts#App_getHostContext_accessAfterConnection" + * await app.connect(transport); + * const context = app.getHostContext(); + * if (context?.theme === "dark") { + * document.body.classList.add("dark-theme"); + * } + * if (context?.toolInfo) { + * console.log("Tool:", context.toolInfo.tool.name); + * } + * ``` * * @see {@link connect `connect`} for the initialization handshake * @see {@link onhostcontextchanged `onhostcontextchanged`} for context change notifications @@ -264,7 +317,14 @@ export class App extends Protocol { * @param callback - Function called with the tool input params ({@link McpUiToolInputNotification.params `McpUiToolInputNotification.params`}) * * @example - * {@includeCode ./app.examples.ts#App_ontoolinput_setter} + * ```ts source="./app.examples.ts#App_ontoolinput_setter" + * // Register before connecting to ensure no notifications are missed + * app.ontoolinput = (params) => { + * console.log("Tool:", params.arguments); + * // Update your UI with the tool arguments + * }; + * await app.connect(); + * ``` * * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method * @see {@link McpUiToolInputNotification `McpUiToolInputNotification`} for the notification structure @@ -297,7 +357,29 @@ export class App extends Protocol { * @param callback - Function called with each partial tool input update ({@link McpUiToolInputPartialNotification.params `McpUiToolInputPartialNotification.params`}) * * @example Progressive rendering of tool arguments - * {@includeCode ./app.examples.ts#App_ontoolinputpartial_progressiveRendering} + * ```ts source="./app.examples.ts#App_ontoolinputpartial_progressiveRendering" + * let toolInputs: Record | null = null; + * let toolInputsPartial: Record | null = null; + * + * app.ontoolinputpartial = (params) => { + * 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 + * } + * } + * ``` * * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method * @see {@link McpUiToolInputPartialNotification `McpUiToolInputPartialNotification`} for the notification structure @@ -326,7 +408,15 @@ export class App extends Protocol { * @param callback - Function called with the tool result ({@link McpUiToolResultNotification.params `McpUiToolResultNotification.params`}) * * @example Display tool execution results - * {@includeCode ./app.examples.ts#App_ontoolresult_displayResults} + * ```ts source="./app.examples.ts#App_ontoolresult_displayResults" + * app.ontoolresult = (params) => { + * if (params.isError) { + * console.error("Tool execution failed:", params.content); + * } else if (params.content) { + * console.log("Tool output:", params.content); + * } + * }; + * ``` * * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method * @see {@link McpUiToolResultNotification `McpUiToolResultNotification`} for the notification structure @@ -356,7 +446,12 @@ export class App extends Protocol { * @param callback - Function called when tool execution is cancelled. Receives optional cancellation reason — see {@link McpUiToolCancelledNotification.params `McpUiToolCancelledNotification.params`}. * * @example Handle tool cancellation - * {@includeCode ./app.examples.ts#App_ontoolcancelled_handleCancellation} + * ```ts source="./app.examples.ts#App_ontoolcancelled_handleCancellation" + * app.ontoolcancelled = (params) => { + * console.log("Tool cancelled:", params.reason); + * // Update your UI to show cancellation state + * }; + * ``` * * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method * @see {@link McpUiToolCancelledNotification `McpUiToolCancelledNotification`} for the notification structure @@ -390,7 +485,15 @@ export class App extends Protocol { * @param callback - Function called with the updated host context * * @example Respond to theme changes - * {@includeCode ./app.examples.ts#App_onhostcontextchanged_respondToTheme} + * ```ts source="./app.examples.ts#App_onhostcontextchanged_respondToTheme" + * app.onhostcontextchanged = (params) => { + * if (params.theme === "dark") { + * document.body.classList.add("dark-theme"); + * } else { + * document.body.classList.remove("dark-theme"); + * } + * }; + * ``` * * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method * @see {@link McpUiHostContextChangedNotification `McpUiHostContextChangedNotification`} for the notification structure @@ -428,7 +531,14 @@ export class App extends Protocol { * Must return `McpUiResourceTeardownResult` (can be an empty object `{}`) or a Promise resolving to it. * * @example Perform cleanup before teardown - * {@includeCode ./app.examples.ts#App_onteardown_performCleanup} + * ```ts source="./app.examples.ts#App_onteardown_performCleanup" + * app.onteardown = async () => { + * await saveState(); + * closeConnections(); + * console.log("App ready for teardown"); + * return {}; + * }; + * ``` * * @see {@link setRequestHandler `setRequestHandler`} for the underlying method * @see {@link McpUiResourceTeardownRequest `McpUiResourceTeardownRequest`} for the request structure @@ -464,7 +574,15 @@ export class App extends Protocol { * in the constructor. * * @example Handle tool calls from the host - * {@includeCode ./app.examples.ts#App_oncalltool_handleFromHost} + * ```ts source="./app.examples.ts#App_oncalltool_handleFromHost" + * app.oncalltool = async (params, extra) => { + * if (params.name === "greet") { + * const name = params.arguments?.name ?? "World"; + * return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + * } + * throw new Error(`Unknown tool: ${params.name}`); + * }; + * ``` * * @see {@link setRequestHandler `setRequestHandler`} for the underlying method */ @@ -498,7 +616,13 @@ export class App extends Protocol { * allowed; capability validation occurs when handlers are invoked. * * @example Return available tools - * {@includeCode ./app.examples.ts#App_onlisttools_returnTools} + * ```ts source="./app.examples.ts#App_onlisttools_returnTools" + * app.onlisttools = async (params, extra) => { + * return { + * tools: ["greet", "calculate", "format"], + * }; + * }; + * ``` * * @see {@link setRequestHandler `setRequestHandler`} for the underlying method * @see {@link oncalltool `oncalltool`} for handling tool execution @@ -587,7 +711,21 @@ export class App extends Protocol { * between transport failures (thrown) and tool execution failures (returned). * * @example Fetch updated weather data - * {@includeCode ./app.examples.ts#App_callServerTool_fetchWeather} + * ```ts source="./app.examples.ts#App_callServerTool_fetchWeather" + * try { + * const result = await app.callServerTool({ + * name: "get_weather", + * arguments: { location: "Tokyo" }, + * }); + * if (result.isError) { + * console.error("Tool returned error:", result.content); + * } else { + * console.log(result.content); + * } + * } catch (error) { + * console.error("Tool call failed:", error); + * } + * ``` */ async callServerTool( params: CallToolRequest["params"], @@ -613,10 +751,40 @@ export class App extends Protocol { * @throws {Error} If the request times out or the connection is lost * * @example Send a text message from user interaction - * {@includeCode ./app.examples.ts#App_sendMessage_textFromInteraction} + * ```ts source="./app.examples.ts#App_sendMessage_textFromInteraction" + * try { + * const result = await app.sendMessage({ + * role: "user", + * content: [{ type: "text", text: "Show me details for item #42" }], + * }); + * if (result.isError) { + * console.error("Host rejected the message"); + * // Handle rejection appropriately for your app + * } + * } catch (error) { + * console.error("Failed to send message:", error); + * // Handle transport/protocol error + * } + * ``` * * @example Send follow-up message after offloading large data to model context - * {@includeCode ./app.examples.ts#App_sendMessage_withLargeContext} + * ```ts source="./app.examples.ts#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" }], + * }); + * ``` * * @see {@link McpUiMessageRequest `McpUiMessageRequest`} for request structure */ @@ -640,7 +808,13 @@ export class App extends Protocol { * @param params - Log level and message * * @example Log app state for debugging - * {@includeCode ./app.examples.ts#App_sendLog_debugState} + * ```ts source="./app.examples.ts#App_sendLog_debugState" + * app.sendLog({ + * level: "info", + * data: "Weather data refreshed", + * logger: "WeatherApp", + * }); + * ``` * * @returns Promise that resolves when the log notification is sent */ @@ -668,10 +842,39 @@ export class App extends Protocol { * @throws {Error} If the request times out or the connection is lost * * @example Update model context with current app state - * {@includeCode ./app.examples.ts#App_updateModelContext_appState} + * ```ts source="./app.examples.ts#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: markdown }], + * }); + * ``` * * @example Report runtime error to model - * {@includeCode ./app.examples.ts#App_updateModelContext_reportError} + * ```ts source="./app.examples.ts#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", + * }, + * ], + * }); + * } + * ``` * * @returns Promise that resolves when the context update is acknowledged */ @@ -702,7 +905,14 @@ export class App extends Protocol { * @throws {Error} If the request times out or the connection is lost * * @example Open documentation link - * {@includeCode ./app.examples.ts#App_openLink_documentation} + * ```ts source="./app.examples.ts#App_openLink_documentation" + * const { isError } = await app.openLink({ url: "https://docs.example.com" }); + * if (isError) { + * // Host denied the request (e.g., blocked domain, user cancelled) + * // Optionally show fallback: display URL for manual copy + * console.warn("Link request denied"); + * } + * ``` * * @see {@link McpUiOpenLinkRequest `McpUiOpenLinkRequest`} for request structure * @see {@link McpUiOpenLinkResult `McpUiOpenLinkResult`} for result structure @@ -734,7 +944,14 @@ export class App extends Protocol { * @returns Result containing the actual display mode that was set * * @example Toggle display mode - * {@includeCode ./app.examples.ts#App_requestDisplayMode_toggle} + * ```ts source="./app.examples.ts#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); + * } + * ``` * * @see {@link McpUiRequestDisplayModeRequest `McpUiRequestDisplayModeRequest`} for request structure * @see {@link McpUiHostContext `McpUiHostContext`} for checking availableDisplayModes @@ -762,7 +979,12 @@ export class App extends Protocol { * @param params - New width and height in pixels * * @example Manually notify host of size change - * {@includeCode ./app.examples.ts#App_sendSizeChanged_manual} + * ```ts source="./app.examples.ts#App_sendSizeChanged_manual" + * app.sendSizeChanged({ + * width: 400, + * height: 600, + * }); + * ``` * * @returns Promise that resolves when the notification is sent * @@ -789,7 +1011,20 @@ export class App extends Protocol { * @returns Cleanup function to disconnect the observer * * @example Manual setup for custom scenarios - * {@includeCode ./app.examples.ts#App_setupAutoResize_manual} + * ```ts source="./app.examples.ts#App_setupAutoResize_manual" + * const app = new App( + * { name: "MyApp", version: "1.0.0" }, + * {}, + * { autoResize: false }, + * ); + * await app.connect(transport); + * + * // Later, enable auto-resize manually + * const cleanup = app.setupSizeChangedNotifications(); + * + * // Clean up when done + * cleanup(); + * ``` */ setupSizeChangedNotifications() { let scheduled = false; @@ -861,7 +1096,16 @@ export class App extends Protocol { * @throws {Error} If initialization fails or connection is lost * * @example Connect with PostMessageTransport - * {@includeCode ./app.examples.ts#App_connect_withPostMessageTransport} + * ```ts source="./app.examples.ts#App_connect_withPostMessageTransport" + * const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + * + * try { + * await app.connect(new PostMessageTransport(window.parent, window.parent)); + * console.log("Connected successfully!"); + * } catch (error) { + * console.error("Failed to connect:", error); + * } + * ``` * * @see {@link McpUiInitializeRequest `McpUiInitializeRequest`} for the initialization request structure * @see {@link McpUiInitializedNotification `McpUiInitializedNotification`} for the initialized notification diff --git a/src/message-transport.ts b/src/message-transport.ts index 6c8d6c2a..c49aa77c 100644 --- a/src/message-transport.ts +++ b/src/message-transport.ts @@ -24,10 +24,20 @@ import { * ## Usage * * **View**: - * {@includeCode ./message-transport.examples.ts#PostMessageTransport_view} + * ```ts source="./message-transport.examples.ts#PostMessageTransport_view" + * const transport = new PostMessageTransport(window.parent, window.parent); + * await app.connect(transport); + * ``` * * **Host**: - * {@includeCode ./message-transport.examples.ts#PostMessageTransport_host} + * ```ts source="./message-transport.examples.ts#PostMessageTransport_host" + * const iframe = document.getElementById("app-iframe") as HTMLIFrameElement; + * const transport = new PostMessageTransport( + * iframe.contentWindow!, + * iframe.contentWindow!, + * ); + * await bridge.connect(transport); + * ``` * * @see {@link app!App.connect `App.connect`} for View usage * @see {@link app-bridge!AppBridge.connect `AppBridge.connect`} for Host usage @@ -46,10 +56,18 @@ export class PostMessageTransport implements Transport { * `window.parent`. For hosts, pass `iframe.contentWindow`. * * @example View connecting to parent - * {@includeCode ./message-transport.examples.ts#PostMessageTransport_constructor_view} + * ```ts source="./message-transport.examples.ts#PostMessageTransport_constructor_view" + * const transport = new PostMessageTransport(window.parent, window.parent); + * ``` * * @example Host connecting to iframe - * {@includeCode ./message-transport.examples.ts#PostMessageTransport_constructor_host} + * ```ts source="./message-transport.examples.ts#PostMessageTransport_constructor_host" + * const iframe = document.getElementById("app-iframe") as HTMLIFrameElement; + * const transport = new PostMessageTransport( + * iframe.contentWindow!, + * iframe.contentWindow!, + * ); + * ``` */ constructor( private eventTarget: Window = window.parent, diff --git a/src/react/index.tsx b/src/react/index.tsx index 4057684d..e76e197a 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -17,7 +17,19 @@ * @module @modelcontextprotocol/ext-apps/react * * @example Basic React App - * {@includeCode ./index.examples.tsx#index_basicReactApp} + * ```tsx source="./index.examples.tsx#index_basicReactApp" + * function MyApp() { + * const { app, isConnected, error } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * }); + * + * if (error) return
Error: {error.message}
; + * if (!isConnected) return
Connecting...
; + * + * return
Connected!
; + * } + * ``` */ export * from "./useApp"; export * from "./useAutoResize"; diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index 0c823cde..be1fb032 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -30,7 +30,17 @@ export interface UseAppOptions { * @param app - The newly created `App` instance * * @example Register an event handler - * {@includeCode ./useApp.examples.tsx#useApp_registerHandler} + * ```tsx source="./useApp.examples.tsx#useApp_registerHandler" + * useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * onAppCreated: (app) => { + * app.ontoolresult = (result) => { + * console.log("Tool result:", result); + * }; + * }, + * }); + * ``` */ onAppCreated?: (app: App) => void; } @@ -70,7 +80,39 @@ export interface AppState { * timeouts, initialization handshake failures, or transport errors). * * @example Basic usage of useApp hook with common event handlers - * {@includeCode ./useApp.examples.tsx#useApp_basicUsage} + * ```tsx source="./useApp.examples.tsx#useApp_basicUsage" + * function MyApp() { + * const [hostContext, setHostContext] = useState< + * McpUiHostContext | undefined + * >(undefined); + * + * const { app, isConnected, error } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * onAppCreated: (app) => { + * app.ontoolinput = (input) => { + * console.log("Tool input:", input); + * }; + * app.ontoolresult = (result) => { + * console.log("Tool result:", result); + * }; + * app.ontoolcancelled = (params) => { + * console.log("Tool cancelled:", params.reason); + * }; + * app.onerror = (error) => { + * console.log("Error:", error); + * }; + * app.onhostcontextchanged = (params) => { + * setHostContext((prev) => ({ ...prev, ...params })); + * }; + * }, + * }); + * + * if (error) return
Error: {error.message}
; + * if (!isConnected) return
Connecting...
; + * return
Theme: {hostContext?.theme}
; + * } + * ``` * * @see {@link App.connect `App.connect`} for the underlying connection method * @see {@link useAutoResize `useAutoResize`} for manual auto-resize control when using custom App options diff --git a/src/react/useAutoResize.ts b/src/react/useAutoResize.ts index a1494fed..78217d6b 100644 --- a/src/react/useAutoResize.ts +++ b/src/react/useAutoResize.ts @@ -20,7 +20,33 @@ import { App } from "../app"; * cause unnecessary effect re-runs; omit this parameter. * * @example Manual App creation with custom auto-resize control - * {@includeCode ./useAutoResize.examples.tsx#useAutoResize_manualApp} + * ```tsx source="./useAutoResize.examples.tsx#useAutoResize_manualApp" + * function MyComponent() { + * // For custom App options, create App manually instead of using useApp + * const [app, setApp] = useState(null); + * const [error, setError] = useState(null); + * + * useEffect(() => { + * const myApp = new App( + * { name: "MyApp", version: "1.0.0" }, + * {}, // capabilities + * { autoResize: false }, // Disable default auto-resize + * ); + * + * const transport = new PostMessageTransport(window.parent, window.parent); + * myApp + * .connect(transport) + * .then(() => setApp(myApp)) + * .catch((err) => setError(err)); + * }, []); + * + * // Add manual auto-resize control + * useAutoResize(app); + * + * if (error) return
Connection failed: {error.message}
; + * return
My content
; + * } + * ``` * * @see {@link App.setupSizeChangedNotifications `App.setupSizeChangedNotifications`} for the underlying implementation * @see {@link useApp `useApp`} which enables auto-resize by default diff --git a/src/react/useDocumentTheme.ts b/src/react/useDocumentTheme.ts index f3127b55..2fdfcc2f 100644 --- a/src/react/useDocumentTheme.ts +++ b/src/react/useDocumentTheme.ts @@ -15,10 +15,31 @@ import { McpUiTheme } from "../types"; * @returns The current theme ("light" or "dark") * * @example Conditionally render based on theme - * {@includeCode ./useDocumentTheme.examples.tsx#useDocumentTheme_conditionalRender} + * ```tsx source="./useDocumentTheme.examples.tsx#useDocumentTheme_conditionalRender" + * function MyApp() { + * const theme = useDocumentTheme(); + * + * return
{theme === "dark" ? : }
; + * } + * ``` * * @example Use with theme-aware styling - * {@includeCode ./useDocumentTheme.examples.tsx#useDocumentTheme_themedButton} + * ```tsx source="./useDocumentTheme.examples.tsx#useDocumentTheme_themedButton" + * function ThemedButton() { + * const theme = useDocumentTheme(); + * + * return ( + * + * ); + * } + * ``` * * @see {@link getDocumentTheme `getDocumentTheme`} for the underlying function * @see {@link applyDocumentTheme `applyDocumentTheme`} to set the theme diff --git a/src/react/useHostStyles.ts b/src/react/useHostStyles.ts index 38d21839..72247f75 100644 --- a/src/react/useHostStyles.ts +++ b/src/react/useHostStyles.ts @@ -26,7 +26,23 @@ import { McpUiHostContext } from "../types"; * If provided, styles and theme will be applied immediately on mount. * * @example - * {@includeCode ./useHostStyles.examples.tsx#useHostStyleVariables_basicUsage} + * ```tsx source="./useHostStyles.examples.tsx#useHostStyleVariables_basicUsage" + * function MyApp() { + * const { app } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * }); + * + * // Apply host styles - pass initial context to apply styles from connect() immediately + * useHostStyleVariables(app, app?.getHostContext()); + * + * return ( + *
+ * Hello! + *
+ * ); + * } + * ``` * * @see {@link applyHostStyleVariables `applyHostStyleVariables`} for the underlying styles function * @see {@link applyDocumentTheme `applyDocumentTheme`} for the underlying theme function @@ -89,7 +105,19 @@ export function useHostStyleVariables( * If provided, fonts will be applied immediately on mount. * * @example Basic usage with useApp - * {@includeCode ./useHostStyles.examples.tsx#useHostFonts_basicUsage} + * ```tsx source="./useHostStyles.examples.tsx#useHostFonts_basicUsage" + * function MyApp() { + * const { app } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * }); + * + * // Apply host fonts - pass initial context to apply fonts from connect() immediately + * useHostFonts(app, app?.getHostContext()); + * + * return
Hello!
; + * } + * ``` * * @see {@link applyHostFonts `applyHostFonts`} for the underlying fonts function * @see {@link useHostStyleVariables `useHostStyleVariables`} for applying style variables and theme @@ -136,7 +164,23 @@ export function useHostFonts( * Pass `app?.getHostContext()` to apply styles immediately on mount. * * @example - * {@includeCode ./useHostStyles.examples.tsx#useHostStyles_basicUsage} + * ```tsx source="./useHostStyles.examples.tsx#useHostStyles_basicUsage" + * function MyApp() { + * const { app } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * }); + * + * // Apply all host styles - pass initial context to apply styles from connect() immediately + * useHostStyles(app, app?.getHostContext()); + * + * return ( + *
+ * Hello! + *
+ * ); + * } + * ``` * * @see {@link useHostStyleVariables `useHostStyleVariables`} for style variables and theme only * @see {@link useHostFonts `useHostFonts`} for fonts only diff --git a/src/server/index.ts b/src/server/index.ts index 77892528..56308d84 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,7 +8,27 @@ * @module server-helpers * * @example - * {@includeCode ./index.examples.ts#index_overview} + * ```ts source="./index.examples.ts#index_overview" + * // Register a tool that displays a view + * registerAppTool( + * server, + * "weather", + * { + * description: "Get weather forecast", + * _meta: { ui: { resourceUri: "ui://weather/view.html" } }, + * }, + * toolCallback, + * ); + * + * // Register the HTML resource the tool references + * registerAppResource( + * server, + * "Weather View", + * "ui://weather/view.html", + * {}, + * readCallback, + * ); + * ``` */ import { @@ -115,13 +135,67 @@ export interface McpUiAppResourceConfig extends ResourceMetadata { * @param cb - Tool handler function * * @example Basic usage - * {@includeCode ./index.examples.ts#registerAppTool_basicUsage} + * ```ts source="./index.examples.ts#registerAppTool_basicUsage" + * registerAppTool( + * server, + * "get-weather", + * { + * title: "Get Weather", + * description: "Get current weather for a location", + * inputSchema: { location: z.string() }, + * _meta: { + * ui: { resourceUri: "ui://weather/view.html" }, + * }, + * }, + * async (args) => { + * const weather = await fetchWeather(args.location); + * return { content: [{ type: "text", text: JSON.stringify(weather) }] }; + * }, + * ); + * ``` * * @example Tool visible to model but not callable by UI - * {@includeCode ./index.examples.ts#registerAppTool_modelOnlyVisibility} + * ```ts source="./index.examples.ts#registerAppTool_modelOnlyVisibility" + * registerAppTool( + * server, + * "show-cart", + * { + * description: "Display the user's shopping cart", + * _meta: { + * ui: { + * resourceUri: "ui://shop/cart.html", + * visibility: ["model"], + * }, + * }, + * }, + * async () => { + * const cart = await getCart(); + * return { content: [{ type: "text", text: JSON.stringify(cart) }] }; + * }, + * ); + * ``` * * @example Tool hidden from model, only callable by UI - * {@includeCode ./index.examples.ts#registerAppTool_appOnlyVisibility} + * ```ts source="./index.examples.ts#registerAppTool_appOnlyVisibility" + * registerAppTool( + * server, + * "update-quantity", + * { + * description: "Update item quantity in cart", + * inputSchema: { itemId: z.string(), quantity: z.number() }, + * _meta: { + * ui: { + * resourceUri: "ui://shop/cart.html", + * visibility: ["app"], + * }, + * }, + * }, + * async ({ itemId, quantity }) => { + * const cart = await updateCartItem(itemId, quantity); + * return { content: [{ type: "text", text: JSON.stringify(cart) }] }; + * }, + * ); + * ``` * * @see {@link registerAppResource `registerAppResource`} to register the HTML resource referenced by the tool */ @@ -170,10 +244,54 @@ export function registerAppTool< * @param readCallback - Callback that returns the resource contents * * @example Basic usage - * {@includeCode ./index.examples.ts#registerAppResource_basicUsage} + * ```ts source="./index.examples.ts#registerAppResource_basicUsage" + * registerAppResource( + * server, + * "Weather View", + * "ui://weather/view.html", + * { + * description: "Interactive weather display", + * }, + * async () => ({ + * contents: [ + * { + * uri: "ui://weather/view.html", + * mimeType: RESOURCE_MIME_TYPE, + * text: await fs.readFile("dist/view.html", "utf-8"), + * }, + * ], + * }), + * ); + * ``` * * @example With CSP configuration for external domains - * {@includeCode ./index.examples.ts#registerAppResource_withCsp} + * ```ts source="./index.examples.ts#registerAppResource_withCsp" + * registerAppResource( + * server, + * "Music Player", + * "ui://music/player.html", + * { + * description: "Audio player with external soundfonts", + * }, + * async () => ({ + * contents: [ + * { + * uri: "ui://music/player.html", + * mimeType: RESOURCE_MIME_TYPE, + * text: musicPlayerHtml, + * _meta: { + * ui: { + * csp: { + * resourceDomains: ["https://cdn.example.com"], // For scripts/styles/images + * connectDomains: ["https://api.example.com"], // For fetch/WebSocket + * }, + * }, + * }, + * }, + * ], + * }), + * ); + * ``` * * @see {@link registerAppTool `registerAppTool`} to register tools that reference this resource */ diff --git a/src/styles.ts b/src/styles.ts index 055fe0f1..0fcfdd30 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -10,7 +10,10 @@ import { McpUiStyles, McpUiTheme } from "./types"; * @returns The current theme ("light" or "dark") * * @example Check current theme - * {@includeCode ./styles.examples.ts#getDocumentTheme_checkCurrent} + * ```ts source="./styles.examples.ts#getDocumentTheme_checkCurrent" + * const theme = getDocumentTheme(); + * document.body.classList.toggle("dark", theme === "dark"); + * ``` * * @see {@link applyDocumentTheme `applyDocumentTheme`} to set the theme * @see {@link McpUiTheme `McpUiTheme`} for the theme type @@ -39,7 +42,22 @@ export function getDocumentTheme(): McpUiTheme { * @param theme - The theme to apply ("light" or "dark") * * @example Apply theme from host context - * {@includeCode ./styles.examples.ts#applyDocumentTheme_fromHostContext} + * ```ts source="./styles.examples.ts#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); + * } + * }); + * ``` * * @example Use with CSS selectors * ```css @@ -72,10 +90,35 @@ export function applyDocumentTheme(theme: McpUiTheme): void { * @param root - The element to apply styles to (defaults to `document.documentElement`) * * @example Apply style variables from host context - * {@includeCode ./styles.examples.ts#applyHostStyleVariables_fromHostContext} + * ```ts source="./styles.examples.ts#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); + * } + * }); + * ``` * * @example Apply to a specific element - * {@includeCode ./styles.examples.ts#applyHostStyleVariables_toElement} + * ```ts source="./styles.examples.ts#applyHostStyleVariables_toElement" + * app.onhostcontextchanged = (params) => { + * const container = document.getElementById("app-root"); + * if (container && params.styles?.variables) { + * applyHostStyleVariables(params.styles.variables, container); + * } + * }; + * ``` * * @example Use host style variables in CSS * ```css @@ -118,13 +161,44 @@ export function applyHostStyleVariables( * @param fontCss - CSS string containing `@font-face` rules and/or `@import` statements * * @example Apply fonts from host context - * {@includeCode ./styles.examples.ts#applyHostFonts_fromHostContext} + * ```ts source="./styles.examples.ts#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); + * } + * }); + * ``` * * @example Host providing self-hosted fonts - * {@includeCode ./styles.examples.ts#applyHostFonts_selfHosted} + * ```ts source="./styles.examples.ts#applyHostFonts_selfHosted" + * // Example of what a host might provide: + * const fontCss = ` + * @font-face { + * font-family: "Anthropic Sans"; + * src: url("https://assets.anthropic.com/.../Regular.otf") format("opentype"); + * font-weight: 400; + * } + * `; + * applyHostFonts(fontCss); + * ``` * * @example Host providing Google Fonts - * {@includeCode ./styles.examples.ts#applyHostFonts_googleFonts} + * ```ts source="./styles.examples.ts#applyHostFonts_googleFonts" + * // Example of what a host might provide: + * const fontCss = ` + * @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); + * `; + * applyHostFonts(fontCss); + * ``` * * @example Use host fonts in CSS * ```css