Merge pull request #314 from jonathanhefner/patterns-doc
Add Patterns guide doc
This commit is contained in:
commit
e719be3cd7
14 changed files with 643 additions and 85 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
116
docs/patterns.md
Normal file
116
docs/patterns.md
Normal file
|
|
@ -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._
|
||||
315
docs/patterns.tsx
Normal file
315
docs/patterns.tsx
Normal file
|
|
@ -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<CallToolResult> => {
|
||||
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<Uint8Array>;
|
||||
|
||||
/**
|
||||
* 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<Uint8Array> {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--color-background-primary)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<p>Styled with host CSS variables and fonts</p>
|
||||
<p className="theme-aware">Uses [data-theme] selectors</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
//#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<T>(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>(): 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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | null = null;
|
||||
let toolInputsPartial: Record<string, unknown> | null = null;
|
||||
|
||||
app.ontoolinputpartial = (params) => {
|
||||
console.log("Partial args:", params.arguments);
|
||||
// Update your UI progressively as arguments stream in
|
||||
toolInputsPartial = params.arguments as Record<string, unknown>;
|
||||
render();
|
||||
};
|
||||
|
||||
app.ontoolinput = (params) => {
|
||||
toolInputs = params.arguments as Record<string, unknown>;
|
||||
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<string, unknown> | null): void;
|
||||
declare function renderFinalUI(data: Record<string, unknown>): 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
28
src/app.ts
28
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<AppRequest, AppNotification, AppResult> {
|
||||
private _hostCapabilities?: McpUiHostCapabilities;
|
||||
|
|
@ -287,6 +284,11 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
|
|||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* @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<AppRequest, AppNotification, AppResult> {
|
|||
/**
|
||||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* @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<AppRequest, AppNotification, AppResult> {
|
|||
* @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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue