diff --git a/docs/openai-migration.md b/docs/openai-migration.md new file mode 100644 index 00000000..4d534f94 --- /dev/null +++ b/docs/openai-migration.md @@ -0,0 +1,198 @@ +# Migrating from OpenAI Apps SDK to MCP Apps SDK + +This guide helps you migrate from the OpenAI Apps SDK (`window.openai.*`) to the MCP Apps SDK (`@modelcontextprotocol/ext-apps`). + +## Quick Start Comparison + +| OpenAI Apps SDK | MCP Apps SDK | +| --------------------------------- | ---------------------------------- | +| Implicit global (`window.openai`) | Explicit instance (`new App(...)`) | +| Properties pre-populated on load | Async connection + notifications | +| Sync property access | Getters + event handlers | + +## Setup & Connection + +| OpenAI | MCP Apps | Notes | +| -------------------------------- | -------------------------------------------------- | ------------------------------------------------------ | +| `window.openai` (auto-available) | `const app = new App({name, version}, {})` | MCP requires explicit instantiation | +| (implicit) | `await app.connect()` | MCP requires async connection; auto-detects OpenAI env | +| — | `await app.connect(new OpenAITransport())` | Force OpenAI mode explicitly | +| — | `await app.connect(new PostMessageTransport(...))` | Force MCP mode explicitly | + +## Host Context Properties + +| OpenAI | MCP Apps | Notes | +| --------------------------- | --------------------------------------------- | --------------------------------------- | +| `window.openai.theme` | `app.getHostContext()?.theme` | `"light"` \| `"dark"` | +| `window.openai.locale` | `app.getHostContext()?.locale` | BCP 47 language tag (e.g., `"en-US"`) | +| `window.openai.displayMode` | `app.getHostContext()?.displayMode` | `"inline"` \| `"pip"` \| `"fullscreen"` | +| `window.openai.maxHeight` | `app.getHostContext()?.viewport?.maxHeight` | Max container height in px | +| `window.openai.safeArea` | `app.getHostContext()?.safeAreaInsets` | `{ top, right, bottom, left }` | +| `window.openai.userAgent` | `app.getHostContext()?.userAgent` | Host user agent string | +| — | `app.getHostContext()?.availableDisplayModes` | MCP adds: which modes host supports | +| — | `app.getHostContext()?.toolInfo` | MCP adds: tool metadata during call | + +## Tool Data (Input/Output) + +| OpenAI | MCP Apps | Notes | +| ------------------------------------ | ---------------------------------------------------- | ----------------------------------- | +| `window.openai.toolInput` | `app.ontoolinput = (params) => { params.arguments }` | Tool arguments; MCP uses callback | +| `window.openai.toolOutput` | `app.ontoolresult = (params) => { params.content }` | Tool result; MCP uses callback | +| `window.openai.toolResponseMetadata` | `app.ontoolresult` → `params._meta` | Widget-only metadata from server | +| — | `app.ontoolinputpartial = (params) => {...}` | MCP adds: streaming partial args | +| — | `app.ontoolcancelled = (params) => {...}` | MCP adds: cancellation notification | + +## Calling Tools + +| OpenAI | MCP Apps | Notes | +| ---------------------------------------------------- | ----------------------------------------------------- | --------------------------------------- | +| `await window.openai.callTool(name, args)` | `await app.callServerTool({ name, arguments: args })` | Call another MCP server tool | +| Returns `{ structuredContent?, content?, isError? }` | Returns `{ content, structuredContent?, isError? }` | Same shape, slightly different ordering | + +## Sending Messages + +| OpenAI | MCP Apps | Notes | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------- | +| `await window.openai.sendFollowUpMessage({ prompt })` | `await app.sendMessage({ role: "user", content: [{ type: "text", text: prompt }] })` | MCP uses structured content array | + +## External Links + +| OpenAI | MCP Apps | Notes | +| -------------------------------------------- | ----------------------------------- | ------------------------------------ | +| `await window.openai.openExternal({ href })` | `await app.openLink({ url: href })` | Different param name: `href` → `url` | + +## Display Mode + +| OpenAI | MCP Apps | Notes | +| -------------------------------------------------- | --------------------------------------------------------- | ----------------------------------- | +| `await window.openai.requestDisplayMode({ mode })` | `await app.requestDisplayMode({ mode })` | Same API | +| — | Check `app.getHostContext()?.availableDisplayModes` first | MCP lets you check what's available | + +## Size Reporting + +| OpenAI | MCP Apps | Notes | +| --------------------------------------------- | ----------------------------------------- | ----------------------------------- | +| `window.openai.notifyIntrinsicHeight(height)` | `app.sendSizeChanged({ width, height })` | MCP includes width | +| Manual only | Auto via `{ autoResize: true }` (default) | MCP auto-reports via ResizeObserver | + +## State Persistence + +| OpenAI | MCP Apps | Notes | +| ------------------------------------- | -------------------------------------------------------------------- | ------------------------------ | +| `window.openai.widgetState` | `app.onwidgetstate = (params) => { params.state }` | MCP uses notification callback | +| `window.openai.setWidgetState(state)` | `app.updateModelContext({ modelContent, privateContent, imageIds })` | MCP uses structured format | + +## File Operations + +| OpenAI | MCP Apps | Notes | +| ---------------------------------------------------- | ------------------------------------------ | -------------------- | +| `await window.openai.uploadFile(file)` | `await app.uploadFile(file)` | Returns `{ fileId }` | +| `await window.openai.getFileDownloadUrl({ fileId })` | `await app.getFileDownloadUrl({ fileId })` | Returns `{ url }` | + +## Other (Not Yet in MCP Apps) + +| OpenAI | MCP Apps | Notes | +| ------------------------------------------- | -------- | ------------------- | +| `await window.openai.requestModal(options)` | — | Not yet implemented | +| `window.openai.requestClose()` | — | Not yet implemented | +| `window.openai.view` | — | Not yet mapped | + +## Event Handling + +| OpenAI | MCP Apps | Notes | +| ------------------------------ | ------------------------------------------- | -------------------------------- | +| Read `window.openai.*` on load | `app.ontoolinput = (params) => {...}` | Register before `connect()` | +| Read `window.openai.*` on load | `app.ontoolresult = (params) => {...}` | Register before `connect()` | +| Poll or re-read properties | `app.onhostcontextchanged = (ctx) => {...}` | MCP pushes context changes | +| — | `app.onteardown = async () => {...}` | MCP adds: cleanup before unmount | + +## Logging + +| OpenAI | MCP Apps | Notes | +| ------------------ | --------------------------------------------- | ------------------------------- | +| `console.log(...)` | `app.sendLog({ level: "info", data: "..." })` | MCP provides structured logging | + +## Host Info + +| OpenAI | MCP Apps | Notes | +| ------ | --------------------------- | ------------------------------------------------- | +| — | `app.getHostVersion()` | Returns `{ name, version }` of host | +| — | `app.getHostCapabilities()` | Check `serverTools`, `openLinks`, `logging`, etc. | + +## Full Migration Example + +### Before (OpenAI) + +```typescript +// OpenAI Apps SDK +const theme = window.openai.theme; +const toolArgs = window.openai.toolInput; +const toolResult = window.openai.toolOutput; + +// Call a tool +const result = await window.openai.callTool("get_weather", { city: "Tokyo" }); + +// Send a message +await window.openai.sendFollowUpMessage({ prompt: "Weather updated!" }); + +// Report height +window.openai.notifyIntrinsicHeight(400); + +// Open link +await window.openai.openExternal({ href: "https://example.com" }); +``` + +### After (MCP Apps) + +```typescript +import { App } from "@modelcontextprotocol/ext-apps"; + +const app = new App( + { name: "MyApp", version: "1.0.0" }, + {}, + { autoResize: true }, // auto height reporting +); + +// Register handlers BEFORE connect +app.ontoolinput = (params) => { + console.log("Tool args:", params.arguments); +}; + +app.ontoolresult = (params) => { + console.log("Tool result:", params.content); +}; + +app.onhostcontextchanged = (ctx) => { + if (ctx.theme) applyTheme(ctx.theme); +}; + +// Connect (auto-detects OpenAI vs MCP) +await app.connect(); + +// Access context +const theme = app.getHostContext()?.theme; + +// Call a tool +const result = await app.callServerTool({ + name: "get_weather", + arguments: { city: "Tokyo" }, +}); + +// Send a message +await app.sendMessage({ + role: "user", + content: [{ type: "text", text: "Weather updated!" }], +}); + +// Open link (note: url not href) +await app.openLink({ url: "https://example.com" }); +``` + +## Key Differences Summary + +1. **Initialization**: OpenAI is implicit; MCP requires `new App()` + `await app.connect()` +2. **Data Flow**: OpenAI pre-populates; MCP uses async notifications (register handlers before `connect()`) +3. **Auto-resize**: MCP has built-in ResizeObserver support via `autoResize` option +4. **Structured Content**: MCP uses `{ type: "text", text: "..." }` arrays for messages +5. **Context Changes**: MCP pushes updates via `onhostcontextchanged`; no polling needed +6. **Capabilities**: MCP lets you check what the host supports before calling methods diff --git a/examples/debug-server/README.md b/examples/debug-server/README.md new file mode 100644 index 00000000..89d9a7d2 --- /dev/null +++ b/examples/debug-server/README.md @@ -0,0 +1,55 @@ +# Debug Server + +A comprehensive testing/debugging tool for the MCP Apps SDK that exercises every capability, callback, and result format combination. + +## Tools + +### debug-tool + +Configurable tool for testing all result variations: + +| Parameter | Type | Default | Description | +| -------------------------- | ----------------------------------------------------------------------------------- | -------- | ------------------------------------------- | +| `contentType` | `"text"` \| `"image"` \| `"audio"` \| `"resource"` \| `"resourceLink"` \| `"mixed"` | `"text"` | Content block type to return | +| `multipleBlocks` | boolean | `false` | Return 3 content blocks | +| `includeStructuredContent` | boolean | `true` | Include structuredContent in result | +| `includeMeta` | boolean | `false` | Include \_meta in result | +| `largeInput` | string | - | Large text input (tests tool-input-partial) | +| `simulateError` | boolean | `false` | Return isError: true | +| `delayMs` | number | - | Delay before response (ms) | + +### debug-refresh + +App-only tool (hidden from model) for polling server state. Returns current timestamp and call counter. + +## App UI + +The debug app provides a dashboard with: + +- **Event Log**: Real-time log of all SDK events with filtering +- **Host Info**: Context, capabilities, container dimensions, styles +- **Callback Status**: Table of all callbacks with call counts +- **Actions**: Buttons to test every SDK method: + - Send messages (text/image) + - Logging (debug/info/warning/error) + - Model context updates + - Display mode requests + - Link opening + - Resize controls + - Server tool calls + - File operations + +## Usage + +```bash +# Build +npm run --workspace examples/debug-server build + +# Run standalone +npm run --workspace examples/debug-server serve + +# Run with all examples +npm start +``` + +Then open `http://localhost:8080/basic-host/` and select "Debug MCP App Server" from the dropdown. diff --git a/examples/debug-server/mcp-app.html b/examples/debug-server/mcp-app.html new file mode 100644 index 00000000..6a5ca667 --- /dev/null +++ b/examples/debug-server/mcp-app.html @@ -0,0 +1,225 @@ + + + + + + + Debug App + + +
+ +
+

+ Event Log +
+ + +
+

+
+
+ + +
+

+ Host Info + +

+
+
+
+

Context

+
+
+
+

Capabilities

+
+
+
+

Container

+
+
+
+

Styles Sample

+
+
+
+
+
+ + +
+

+ Callback Status + +

+
+ + + + + + + + + + +
CallbackRegisteredCountLast Payload
+
+
+ + +
+

+ Actions + +

+
+ +
+

Messages

+
+ + +
+
+ +
+
+ + +
+

Logging

+
+ +
+
+ + + + +
+
+ + +
+

Model Context

+
+ + +
+
+ +
+
+ + +
+

Display Mode

+
+ + + +
+
+ + +
+

Links

+
+ + +
+
+ + +
+

Size

+
+ +
+
+ + + +
+
+ Current: measuring... +
+
+ + +
+

Server Tools

+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+ +
+
+ +
+
+ + +
+

Files

+
+ +
+
+ +
+
+ Last fileId: none +
+
+ +
+
+
+
+
+ + + diff --git a/examples/debug-server/package.json b/examples/debug-server/package.json new file mode 100644 index 00000000..c2d85c3a --- /dev/null +++ b/examples/debug-server/package.json @@ -0,0 +1,43 @@ +{ + "name": "@modelcontextprotocol/server-debug", + "version": "0.4.0", + "type": "module", + "description": "Debug MCP App Server for testing all SDK capabilities", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/debug-server" + }, + "license": "MIT", + "main": "server.ts", + "files": [ + "server.ts", + "server-utils.ts", + "dist" + ], + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "bun --watch server.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.4.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^10.1.0", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/debug-server/server-utils.ts b/examples/debug-server/server-utils.ts new file mode 100644 index 00000000..9fe9745a --- /dev/null +++ b/examples/debug-server/server-utils.ts @@ -0,0 +1,72 @@ +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import cors from "cors"; +import type { Request, Response } from "express"; + +export interface ServerOptions { + port: number; + name?: string; +} + +/** + * Starts an MCP server with Streamable HTTP transport in stateless mode. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. + */ +export async function startServer( + createServer: () => McpServer, + options: ServerOptions, +): Promise { + const { port, name = "MCP Server" } = options; + + const app = createMcpExpressApp({ host: "0.0.0.0" }); + app.use(cors()); + + app.all("/mcp", async (req: Request, res: Response) => { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("MCP error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + const httpServer = app.listen(port, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } + console.log(`${name} listening on http://localhost:${port}/mcp`); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/examples/debug-server/server.ts b/examples/debug-server/server.ts new file mode 100644 index 00000000..32b89f3b --- /dev/null +++ b/examples/debug-server/server.ts @@ -0,0 +1,314 @@ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import { appendFileSync } from "node:fs"; +import path from "node:path"; +import { z } from "zod"; +import { startServer } from "./server-utils.js"; + +const DIST_DIR = path.join(import.meta.dirname, "dist"); + +// Track call counter across requests (stateful for demo purposes) +let callCounter = 0; + +// Parse --log-file argument or use default +const DEFAULT_LOG_FILE = "/tmp/mcp-apps-debug-server.log"; +function getLogFilePath(): string { + const logFileArg = process.argv.find((arg) => arg.startsWith("--log-file=")); + if (logFileArg) { + return logFileArg.split("=")[1]; + } + return process.env.DEBUG_LOG_FILE ?? DEFAULT_LOG_FILE; +} + +const logFilePath = getLogFilePath(); + +/** + * Append a log entry to the log file + */ +function appendToLogFile(entry: { + timestamp: string; + type: string; + payload: unknown; +}): void { + try { + const line = JSON.stringify(entry) + "\n"; + appendFileSync(logFilePath, line, "utf-8"); + } catch (e) { + console.error("[debug-server] Failed to write to log file:", e); + } +} + +// Minimal 1x1 blue PNG (base64) +const BLUE_PNG_1X1 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPj/HwADBwIAMCbHYQAAAABJRU5ErkJggg=="; + +// Minimal silent WAV (base64) - 44 byte header + 1 sample +const SILENT_WAV = + "UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAAAAA=="; + +/** + * Input schema for the debug-tool + */ +const DebugInputSchema = z.object({ + // Content configuration + contentType: z + .enum(["text", "image", "audio", "resource", "resourceLink", "mixed"]) + .default("text"), + multipleBlocks: z.boolean().default(false), + includeStructuredContent: z.boolean().default(true), + includeMeta: z.boolean().default(false), + + // Streaming test (large input) + largeInput: z.string().optional(), + + // Error/delay simulation + simulateError: z.boolean().default(false), + delayMs: z.number().optional(), +}); + +type DebugInput = z.infer; + +/** + * Output schema for structured content + */ +const DebugOutputSchema = z.object({ + config: z.record(z.string(), z.unknown()), + timestamp: z.string(), + counter: z.number(), + largeInputLength: z.number().optional(), +}); + +/** + * Builds content blocks based on configuration + */ +function buildContent(args: DebugInput): CallToolResult["content"] { + const count = args.multipleBlocks ? 3 : 1; + const content: CallToolResult["content"] = []; + + for (let i = 0; i < count; i++) { + const suffix = args.multipleBlocks ? ` #${i + 1}` : ""; + + switch (args.contentType) { + case "text": + content.push({ type: "text", text: `Debug text content${suffix}` }); + break; + case "image": + content.push({ + type: "image", + data: BLUE_PNG_1X1, + mimeType: "image/png", + }); + break; + case "audio": + content.push({ + type: "audio", + data: SILENT_WAV, + mimeType: "audio/wav", + }); + break; + case "resource": + content.push({ + type: "resource", + resource: { + uri: `debug://embedded-resource${suffix.replace(/\s/g, "-")}`, + text: `Embedded resource content${suffix}`, + mimeType: "text/plain", + }, + }); + break; + case "resourceLink": + content.push({ + type: "resource_link", + uri: `debug://linked-resource${suffix.replace(/\s/g, "-")}`, + name: `Linked Resource${suffix}`, + mimeType: "text/plain", + }); + break; + case "mixed": + // Return one of each type (ignore multipleBlocks for mixed) + return [ + { type: "text", text: "Mixed content: text block" }, + { type: "image", data: BLUE_PNG_1X1, mimeType: "image/png" }, + { type: "audio", data: SILENT_WAV, mimeType: "audio/wav" }, + ]; + } + } + + return content; +} + +/** + * Creates a new MCP server instance with debug tools registered. + */ +export function createServer(): McpServer { + const server = new McpServer({ + name: "Debug MCP App Server", + version: "1.0.0", + }); + + const resourceUri = "ui://debug-tool/mcp-app.html"; + + // Main debug tool - exercises all result variations + registerAppTool( + server, + "debug-tool", + { + title: "Debug Tool", + description: + "Comprehensive debug tool for testing MCP Apps SDK. Configure content types, error simulation, delays, and more.", + inputSchema: DebugInputSchema, + outputSchema: DebugOutputSchema, + _meta: { ui: { resourceUri } }, + }, + async (args): Promise => { + // Apply delay if requested + if (args.delayMs && args.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, args.delayMs)); + } + + // Build content based on config + const content = buildContent(args); + + // Build result + const result: CallToolResult = { content }; + + // Add structured content if requested + if (args.includeStructuredContent) { + result.structuredContent = { + config: args, + timestamp: new Date().toISOString(), + counter: ++callCounter, + ...(args.largeInput + ? { largeInputLength: args.largeInput.length } + : {}), + }; + } + + // Add _meta if requested + if (args.includeMeta) { + result._meta = { + debugInfo: { + processedAt: Date.now(), + serverVersion: "1.0.0", + }, + }; + } + + // Set error flag if requested + if (args.simulateError) { + result.isError = true; + } + + return result; + }, + ); + + // App-only refresh tool (hidden from model) + registerAppTool( + server, + "debug-refresh", + { + title: "Refresh Debug Info", + description: + "App-only tool for polling server state. Not visible to the model.", + inputSchema: z.object({}), + outputSchema: z.object({ timestamp: z.string(), counter: z.number() }), + _meta: { + ui: { + resourceUri, + visibility: ["app"], + }, + }, + }, + async (): Promise => { + const timestamp = new Date().toISOString(); + return { + content: [{ type: "text", text: `Server timestamp: ${timestamp}` }], + structuredContent: { timestamp, counter: callCounter }, + }; + }, + ); + + // App-only log tool - writes events to log file + registerAppTool( + server, + "debug-log", + { + title: "Log to File", + description: + "App-only tool for logging events to the server log file. Not visible to the model.", + inputSchema: z.object({ + type: z.string(), + payload: z.unknown(), + }), + outputSchema: z.object({ logged: z.boolean(), logFile: z.string() }), + _meta: { + ui: { + resourceUri, + visibility: ["app"], + }, + }, + }, + async (args): Promise => { + const timestamp = new Date().toISOString(); + appendToLogFile({ timestamp, type: args.type, payload: args.payload }); + return { + content: [{ type: "text", text: `Logged to ${logFilePath}` }], + structuredContent: { logged: true, logFile: logFilePath }, + }; + }, + ); + + // Register the resource which returns the bundled HTML/JavaScript for the UI + registerAppResource( + server, + resourceUri, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + return server; +} + +async function main() { + console.log(`[debug-server] Log file: ${logFilePath}`); + appendToLogFile({ + timestamp: new Date().toISOString(), + type: "server-start", + payload: { logFilePath, pid: process.pid }, + }); + + if (process.argv.includes("--stdio")) { + await createServer().connect(new StdioServerTransport()); + } else { + const port = parseInt(process.env.PORT ?? "3102", 10); + await startServer(createServer, { port, name: "Debug MCP App Server" }); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/debug-server/src/global.css b/examples/debug-server/src/global.css new file mode 100644 index 00000000..18863262 --- /dev/null +++ b/examples/debug-server/src/global.css @@ -0,0 +1,33 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 14px; + margin: 0; + padding: 0; +} + +code { + font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code", Consolas, monospace; + font-size: 0.9em; + background: rgba(0, 0, 0, 0.05); + padding: 0.1em 0.3em; + border-radius: 3px; +} + +@media (prefers-color-scheme: dark) { + code { + background: rgba(255, 255, 255, 0.1); + } +} + +button { + cursor: pointer; +} + +input, select, button { + font-family: inherit; + font-size: inherit; +} diff --git a/examples/debug-server/src/mcp-app.css b/examples/debug-server/src/mcp-app.css new file mode 100644 index 00000000..c9d4509c --- /dev/null +++ b/examples/debug-server/src/mcp-app.css @@ -0,0 +1,332 @@ +.main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-success: #16a34a; + --color-warning: #ca8a04; + --color-error: #dc2626; + --color-border: #e5e7eb; + --color-bg-subtle: #f9fafb; + + width: 100%; + max-width: 800px; + padding: 1rem; + margin: 0 auto; +} + +@media (prefers-color-scheme: dark) { + .main { + --color-border: #374151; + --color-bg-subtle: #1f2937; + } +} + +/* Section styling */ +.section { + border: 1px solid var(--color-border); + border-radius: 8px; + margin-bottom: 1rem; + overflow: hidden; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + margin: 0; + font-size: 1rem; + font-weight: 600; + background: var(--color-bg-subtle); + border-bottom: 1px solid var(--color-border); +} + +.collapsible .section-header { + cursor: pointer; + user-select: none; +} + +.collapsible .section-header:hover { + background: var(--color-border); +} + +.toggle-icon { + transition: transform 0.2s; +} + +.collapsed .toggle-icon { + transform: rotate(-90deg); +} + +.collapsed .section-content { + display: none; +} + +.section-content { + padding: 1rem; +} + +.header-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Event log */ +.event-log { + max-height: 200px; + overflow-y: auto; + padding: 0.5rem; + font-family: ui-monospace, monospace; + font-size: 0.85rem; + background: var(--color-bg-subtle); +} + +.log-entry { + padding: 0.25rem 0; + border-bottom: 1px solid var(--color-border); +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-time { + color: #6b7280; + margin-right: 0.5rem; +} + +.log-type { + font-weight: 600; + margin-right: 0.5rem; +} + +.log-type.tool-input { color: var(--color-primary); } +.log-type.tool-input-partial { color: #8b5cf6; } +.log-type.tool-result { color: var(--color-success); } +.log-type.tool-cancelled { color: var(--color-warning); } +.log-type.widget-state { color: #0891b2; } +.log-type.host-context-changed { color: #7c3aed; } +.log-type.teardown { color: #f97316; } +.log-type.call-tool { color: #ec4899; } +.log-type.list-tools { color: #14b8a6; } +.log-type.error { color: var(--color-error); } + +.log-payload { + color: #6b7280; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 400px; + display: inline-block; + vertical-align: bottom; + cursor: pointer; +} + +.log-payload:hover { + white-space: normal; + word-break: break-all; +} + +/* Info grid */ +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +.info-group h3 { + margin: 0 0 0.5rem 0; + font-size: 0.9rem; + color: #6b7280; +} + +.info-group dl { + margin: 0; + font-size: 0.85rem; +} + +.info-group dt { + font-weight: 600; + color: #374151; +} + +.info-group dd { + margin: 0 0 0.5rem 0; + color: #6b7280; +} + +@media (prefers-color-scheme: dark) { + .info-group dt { color: #d1d5db; } + .info-group dd { color: #9ca3af; } +} + +.styles-sample { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.style-swatch { + width: 24px; + height: 24px; + border-radius: 4px; + border: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: white; + text-shadow: 0 0 2px black; +} + +/* Callback table */ +.callback-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.callback-table th, +.callback-table td { + padding: 0.5rem; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.callback-table th { + background: var(--color-bg-subtle); + font-weight: 600; +} + +.callback-table .registered-yes { + color: var(--color-success); +} + +.callback-table .registered-no { + color: #9ca3af; +} + +.callback-table .payload-preview { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + font-family: ui-monospace, monospace; + font-size: 0.8rem; +} + +.callback-table .payload-preview:hover { + white-space: normal; + word-break: break-all; +} + +/* Action groups */ +.action-group { + margin-bottom: 1.5rem; +} + +.action-group:last-child { + margin-bottom: 0; +} + +.action-group h3 { + margin: 0 0 0.75rem 0; + font-size: 0.9rem; + font-weight: 600; + color: #374151; + border-bottom: 1px solid var(--color-border); + padding-bottom: 0.25rem; +} + +@media (prefers-color-scheme: dark) { + .action-group h3 { color: #d1d5db; } +} + +.action-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; +} + +.action-row:last-child { + margin-bottom: 0; +} + +.action-row input[type="text"], +.action-row input[type="url"], +.action-row input[type="number"] { + flex: 1; + padding: 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: transparent; +} + +.action-row input[type="file"] { + flex: 1; +} + +.action-row button { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + background: var(--color-primary); + color: white; + font-weight: 500; +} + +.action-row button:hover { + background: var(--color-primary-hover); +} + +.btn-row { + flex-wrap: wrap; +} + +.btn-small { + padding: 0.375rem 0.75rem !important; + font-size: 0.85rem; +} + +/* Tool config */ +.tool-config { + background: var(--color-bg-subtle); + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 0.75rem; +} + +.config-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.config-row:last-child { + margin-bottom: 0; +} + +.config-row label { + min-width: 120px; + font-size: 0.85rem; +} + +.config-row select, +.config-row input[type="number"] { + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: transparent; +} + +/* Filter select */ +#log-filter { + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: transparent; + font-size: 0.85rem; +} diff --git a/examples/debug-server/src/mcp-app.ts b/examples/debug-server/src/mcp-app.ts new file mode 100644 index 00000000..1c084d2b --- /dev/null +++ b/examples/debug-server/src/mcp-app.ts @@ -0,0 +1,666 @@ +/** + * @file Debug App - Comprehensive testing/debugging tool for the MCP Apps SDK. + * + * This app exercises every capability, callback, and result format combination. + */ +import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import "./global.css"; +import "./mcp-app.css"; + +// ============================================================================ +// Types +// ============================================================================ + +interface LogEntry { + time: number; + type: string; + payload: unknown; +} + +interface AppState { + eventLog: LogEntry[]; + callbackCounts: Map; + lastPayloads: Map; + uploadedFileId: string | null; + autoResizeCleanup: (() => void) | null; + logFilter: string; +} + +// ============================================================================ +// State +// ============================================================================ + +const state: AppState = { + eventLog: [], + callbackCounts: new Map(), + lastPayloads: new Map(), + uploadedFileId: null, + autoResizeCleanup: null, + logFilter: "all", +}; + +// Callbacks we track +const CALLBACKS = [ + "ontoolinput", + "ontoolinputpartial", + "ontoolresult", + "ontoolcancelled", + "onwidgetstate", + "onhostcontextchanged", + "onteardown", + "oncalltool", + "onlisttools", + "onerror", +] as const; + +// ============================================================================ +// DOM Elements +// ============================================================================ + +const mainEl = document.querySelector(".main") as HTMLElement; +const eventLogEl = document.getElementById("event-log")!; +const logFilterEl = document.getElementById("log-filter") as HTMLSelectElement; +const clearLogBtn = document.getElementById("clear-log-btn")!; + +// Host info +const hostContextInfoEl = document.getElementById("host-context-info")!; +const hostCapabilitiesInfoEl = document.getElementById( + "host-capabilities-info", +)!; +const hostContainerInfoEl = document.getElementById("host-container-info")!; +const hostStylesSampleEl = document.getElementById("host-styles-sample")!; + +// Callback status +const callbackTableBodyEl = document.getElementById("callback-table-body")!; + +// Action elements +const messageTextEl = document.getElementById( + "message-text", +) as HTMLInputElement; +const sendMessageTextBtn = document.getElementById("send-message-text-btn")!; +const sendMessageImageBtn = document.getElementById("send-message-image-btn")!; + +const logDataEl = document.getElementById("log-data") as HTMLInputElement; +const logDebugBtn = document.getElementById("log-debug-btn")!; +const logInfoBtn = document.getElementById("log-info-btn")!; +const logWarningBtn = document.getElementById("log-warning-btn")!; +const logErrorBtn = document.getElementById("log-error-btn")!; + +const contextTextEl = document.getElementById( + "context-text", +) as HTMLInputElement; +const updateContextTextBtn = document.getElementById( + "update-context-text-btn", +)!; +const updateContextStructuredBtn = document.getElementById( + "update-context-structured-btn", +)!; + +const displayInlineBtn = document.getElementById("display-inline-btn")!; +const displayFullscreenBtn = document.getElementById("display-fullscreen-btn")!; +const displayPipBtn = document.getElementById("display-pip-btn")!; + +const linkUrlEl = document.getElementById("link-url") as HTMLInputElement; +const openLinkBtn = document.getElementById("open-link-btn")!; + +const autoResizeToggleEl = document.getElementById( + "auto-resize-toggle", +) as HTMLInputElement; +const resize200x100Btn = document.getElementById("resize-200x100-btn")!; +const resize400x300Btn = document.getElementById("resize-400x300-btn")!; +const resize800x600Btn = document.getElementById("resize-800x600-btn")!; +const currentSizeEl = document.getElementById("current-size")!; + +// Tool config elements +const toolContentTypeEl = document.getElementById( + "tool-content-type", +) as HTMLSelectElement; +const toolMultipleBlocksEl = document.getElementById( + "tool-multiple-blocks", +) as HTMLInputElement; +const toolStructuredContentEl = document.getElementById( + "tool-structured-content", +) as HTMLInputElement; +const toolIncludeMetaEl = document.getElementById( + "tool-include-meta", +) as HTMLInputElement; +const toolSimulateErrorEl = document.getElementById( + "tool-simulate-error", +) as HTMLInputElement; +const toolDelayMsEl = document.getElementById( + "tool-delay-ms", +) as HTMLInputElement; +const callDebugToolBtn = document.getElementById("call-debug-tool-btn")!; +const callDebugRefreshBtn = document.getElementById("call-debug-refresh-btn")!; + +// File elements +const fileInputEl = document.getElementById("file-input") as HTMLInputElement; +const uploadFileBtn = document.getElementById("upload-file-btn")!; +const lastFileIdEl = document.getElementById("last-file-id")!; +const getFileUrlBtn = document.getElementById("get-file-url-btn")!; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function formatTime(timestamp: number): string { + const date = new Date(timestamp); + const h = date.getHours().toString().padStart(2, "0"); + const m = date.getMinutes().toString().padStart(2, "0"); + const s = date.getSeconds().toString().padStart(2, "0"); + const ms = date.getMilliseconds().toString().padStart(3, "0"); + return `${h}:${m}:${s}.${ms}`; +} + +function truncatePayload(payload: unknown): string { + const str = JSON.stringify(payload); + if (str.length > 100) { + return str.slice(0, 100) + "..."; + } + return str; +} + +// ============================================================================ +// Rendering Functions +// ============================================================================ + +function renderEventLog(): void { + const filtered = + state.logFilter === "all" + ? state.eventLog + : state.eventLog.filter((e) => e.type === state.logFilter); + + eventLogEl.innerHTML = filtered + .map( + (entry) => ` +
+ [${formatTime(entry.time)}] + ${entry.type}: + ${truncatePayload(entry.payload)} +
+ `, + ) + .join(""); + + // Auto-scroll to bottom + eventLogEl.scrollTop = eventLogEl.scrollHeight; +} + +function renderCallbackStatus(): void { + callbackTableBodyEl.innerHTML = CALLBACKS.map((name) => { + const count = state.callbackCounts.get(name) ?? 0; + const lastPayload = state.lastPayloads.get(name); + const registered = name !== "onerror"; // All callbacks are registered + + return ` + + ${name} + ${registered ? "✓" : "✗"} + ${count} + ${lastPayload ? truncatePayload(lastPayload) : "-"} + + `; + }).join(""); +} + +function renderHostInfo(): void { + const ctx = app.getHostContext(); + const caps = app.getHostCapabilities(); + const version = app.getHostVersion(); + + // Context info + if (ctx) { + hostContextInfoEl.innerHTML = ` +
Theme
${ctx.theme ?? "unknown"}
+
Locale
${ctx.locale ?? "unknown"}
+
TimeZone
${ctx.timeZone ?? "unknown"}
+
Platform
${ctx.platform ?? "unknown"}
+
Display Mode
${ctx.displayMode ?? "unknown"}
+
Host
${version?.name ?? "unknown"} v${version?.version ?? "?"}
+ `; + } else { + hostContextInfoEl.innerHTML = "
No context available
"; + } + + // Capabilities + if (caps) { + hostCapabilitiesInfoEl.innerHTML = ` +
openLinks
${caps.openLinks ? "✓" : "✗"}
+
serverTools
${caps.serverTools ? "✓" : "✗"}
+
serverResources
${caps.serverResources ? "✓" : "✗"}
+
logging
${caps.logging ? "✓" : "✗"}
+
message
${caps.message ? "✓" : "✗"}
+
updateModelContext
${caps.updateModelContext ? "✓" : "✗"}
+ `; + } else { + hostCapabilitiesInfoEl.innerHTML = "
No capabilities available
"; + } + + // Container info + if (ctx?.containerDimensions) { + const dims = ctx.containerDimensions; + hostContainerInfoEl.innerHTML = ` +
Width
${"width" in dims ? dims.width + "px" : `max ${dims.maxWidth ?? "?"}px`}
+
Height
${"height" in dims ? dims.height + "px" : `max ${dims.maxHeight ?? "?"}px`}
+
Safe Area
${ctx.safeAreaInsets ? `T${ctx.safeAreaInsets.top} R${ctx.safeAreaInsets.right} B${ctx.safeAreaInsets.bottom} L${ctx.safeAreaInsets.left}` : "none"}
+ `; + } else { + hostContainerInfoEl.innerHTML = "
No container info
"; + } + + // Styles sample + if (ctx?.styles) { + const styleVars = Object.entries(ctx.styles).slice(0, 6); + hostStylesSampleEl.innerHTML = styleVars + .map(([key, value]) => { + const color = String(value); + return `
`; + }) + .join(""); + } else { + hostStylesSampleEl.innerHTML = "No styles"; + } +} + +function updateCurrentSize(): void { + const w = document.documentElement.scrollWidth; + const h = document.documentElement.scrollHeight; + currentSizeEl.textContent = `${w}x${h}`; +} + +// ============================================================================ +// Event Logging +// ============================================================================ + +/** + * Send a log entry to the server's debug-log tool (writes to file) + */ +async function sendToServerLog(type: string, payload: unknown): Promise { + try { + await app.callServerTool({ + name: "debug-log", + arguments: { type, payload }, + }); + } catch (e) { + // Log to console only - don't call logEvent to avoid infinite loop + console.error("[debug-app] Failed to send log to server:", e); + } +} + +function logEvent(type: string, payload: unknown): void { + const time = Date.now(); + + // Log to console + console.log(`[debug-app] ${type}:`, payload); + + // Update state + const count = (state.callbackCounts.get(type) ?? 0) + 1; + state.callbackCounts.set(type, count); + state.lastPayloads.set(type, payload); + state.eventLog.push({ time, type, payload }); + + // Keep log manageable (max 100 entries) + if (state.eventLog.length > 100) { + state.eventLog.shift(); + } + + renderEventLog(); + renderCallbackStatus(); + + // Send to server log file (async, fire-and-forget) + // Skip sending debug-log results to avoid noise + if ( + type !== "server-tool-result" || + (payload as { name?: string })?.name !== "debug-log" + ) { + sendToServerLog(type, payload); + } +} + +// ============================================================================ +// Safe Area Handling +// ============================================================================ + +function handleHostContextChanged(ctx: McpUiHostContext): void { + if (ctx.safeAreaInsets) { + mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; + mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; + mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; + mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; + } + renderHostInfo(); +} + +// ============================================================================ +// App Instance & Callbacks +// ============================================================================ + +const app = new App( + { name: "Debug App", version: "1.0.0" }, + {}, // capabilities + { autoResize: false }, // We'll manage auto-resize ourselves for toggle demo +); + +// Register ALL callbacks BEFORE connecting +app.ontoolinput = (params) => { + logEvent("tool-input", params); +}; + +app.ontoolinputpartial = (params) => { + logEvent("tool-input-partial", params); +}; + +app.ontoolresult = (result) => { + logEvent("tool-result", result); +}; + +app.ontoolcancelled = (params) => { + logEvent("tool-cancelled", params); +}; + +app.onwidgetstate = (params) => { + logEvent("widget-state", params); +}; + +app.onhostcontextchanged = (ctx) => { + logEvent("host-context-changed", ctx); + handleHostContextChanged(ctx); +}; + +app.onteardown = async (params) => { + logEvent("teardown", params); + return {}; +}; + +app.oncalltool = async (params) => { + logEvent("call-tool", params); + return { + content: [{ type: "text", text: "App handled tool call" }], + }; +}; + +app.onlisttools = async (params) => { + logEvent("list-tools", params); + return { tools: [] }; +}; + +app.onerror = (error) => { + logEvent("error", error); +}; + +// ============================================================================ +// Section Collapsing +// ============================================================================ + +document.querySelectorAll(".section-header[data-toggle]").forEach((header) => { + header.addEventListener("click", () => { + const section = header.closest(".section"); + section?.classList.toggle("collapsed"); + }); +}); + +// ============================================================================ +// Event Log Controls +// ============================================================================ + +logFilterEl.addEventListener("change", () => { + state.logFilter = logFilterEl.value; + renderEventLog(); +}); + +clearLogBtn.addEventListener("click", () => { + state.eventLog = []; + renderEventLog(); +}); + +// ============================================================================ +// Message Actions +// ============================================================================ + +sendMessageTextBtn.addEventListener("click", async () => { + try { + const result = await app.sendMessage({ + role: "user", + content: [{ type: "text", text: messageTextEl.value }], + }); + logEvent("send-message-result", result); + } catch (e) { + logEvent("error", e); + } +}); + +sendMessageImageBtn.addEventListener("click", async () => { + // 1x1 red PNG for testing + const redPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + try { + const result = await app.sendMessage({ + role: "user", + content: [{ type: "image", data: redPng, mimeType: "image/png" }], + }); + logEvent("send-message-result", result); + } catch (e) { + logEvent("error", e); + } +}); + +// ============================================================================ +// Logging Actions +// ============================================================================ + +function sendLog(level: "debug" | "info" | "warning" | "error"): void { + app.sendLog({ level, data: logDataEl.value }); + logEvent("send-log", { level, data: logDataEl.value }); +} + +logDebugBtn.addEventListener("click", () => sendLog("debug")); +logInfoBtn.addEventListener("click", () => sendLog("info")); +logWarningBtn.addEventListener("click", () => sendLog("warning")); +logErrorBtn.addEventListener("click", () => sendLog("error")); + +// ============================================================================ +// Model Context Actions +// ============================================================================ + +updateContextTextBtn.addEventListener("click", async () => { + try { + await app.updateModelContext({ + content: [{ type: "text", text: contextTextEl.value }], + }); + logEvent("update-context", { type: "text", value: contextTextEl.value }); + } catch (e) { + logEvent("error", e); + } +}); + +updateContextStructuredBtn.addEventListener("click", async () => { + try { + await app.updateModelContext({ + structuredContent: { + debugState: { + eventCount: state.eventLog.length, + timestamp: new Date().toISOString(), + uploadedFileId: state.uploadedFileId, + }, + }, + }); + logEvent("update-context", { type: "structured" }); + } catch (e) { + logEvent("error", e); + } +}); + +// ============================================================================ +// Display Mode Actions +// ============================================================================ + +async function requestDisplayMode( + mode: "inline" | "fullscreen" | "pip", +): Promise { + try { + const result = await app.requestDisplayMode({ mode }); + logEvent("display-mode-result", { mode, result }); + } catch (e) { + logEvent("error", e); + } +} + +displayInlineBtn.addEventListener("click", () => requestDisplayMode("inline")); +displayFullscreenBtn.addEventListener("click", () => + requestDisplayMode("fullscreen"), +); +displayPipBtn.addEventListener("click", () => requestDisplayMode("pip")); + +// ============================================================================ +// Link Action +// ============================================================================ + +openLinkBtn.addEventListener("click", async () => { + try { + const result = await app.openLink({ url: linkUrlEl.value }); + logEvent("open-link-result", result); + } catch (e) { + logEvent("error", e); + } +}); + +// ============================================================================ +// Size Controls +// ============================================================================ + +autoResizeToggleEl.addEventListener("change", () => { + if (autoResizeToggleEl.checked) { + if (!state.autoResizeCleanup) { + state.autoResizeCleanup = app.setupSizeChangedNotifications(); + } + } else { + if (state.autoResizeCleanup) { + state.autoResizeCleanup(); + state.autoResizeCleanup = null; + } + } + logEvent("auto-resize-toggle", { enabled: autoResizeToggleEl.checked }); +}); + +function manualResize(width: number, height: number): void { + app.sendSizeChanged({ width, height }); + logEvent("manual-resize", { width, height }); +} + +resize200x100Btn.addEventListener("click", () => manualResize(200, 100)); +resize400x300Btn.addEventListener("click", () => manualResize(400, 300)); +resize800x600Btn.addEventListener("click", () => manualResize(800, 600)); + +// Update current size periodically +setInterval(updateCurrentSize, 1000); + +// ============================================================================ +// Server Tool Actions +// ============================================================================ + +callDebugToolBtn.addEventListener("click", async () => { + const args = { + contentType: toolContentTypeEl.value, + multipleBlocks: toolMultipleBlocksEl.checked, + includeStructuredContent: toolStructuredContentEl.checked, + includeMeta: toolIncludeMetaEl.checked, + simulateError: toolSimulateErrorEl.checked, + delayMs: parseInt(toolDelayMsEl.value, 10) || undefined, + }; + + try { + logEvent("call-server-tool", { name: "debug-tool", arguments: args }); + const result = await app.callServerTool({ + name: "debug-tool", + arguments: args, + }); + logEvent("server-tool-result", result); + } catch (e) { + logEvent("error", e); + } +}); + +callDebugRefreshBtn.addEventListener("click", async () => { + try { + logEvent("call-server-tool", { name: "debug-refresh", arguments: {} }); + const result = await app.callServerTool({ + name: "debug-refresh", + arguments: {}, + }); + logEvent("server-tool-result", result); + } catch (e) { + logEvent("error", e); + } +}); + +// ============================================================================ +// File Operations +// ============================================================================ + +uploadFileBtn.addEventListener("click", async () => { + const file = fileInputEl.files?.[0]; + if (!file) { + logEvent("error", { message: "No file selected" }); + return; + } + + try { + logEvent("upload-file", { + name: file.name, + size: file.size, + type: file.type, + }); + const result = await app.uploadFile(file); + state.uploadedFileId = result.fileId; + lastFileIdEl.textContent = result.fileId; + logEvent("upload-file-result", result); + } catch (e) { + logEvent("error", e); + } +}); + +getFileUrlBtn.addEventListener("click", async () => { + if (!state.uploadedFileId) { + logEvent("error", { message: "No file uploaded yet" }); + return; + } + + try { + logEvent("get-file-url", { fileId: state.uploadedFileId }); + const result = await app.getFileDownloadUrl({ + fileId: state.uploadedFileId, + }); + logEvent("get-file-url-result", result); + } catch (e) { + logEvent("error", e); + } +}); + +// ============================================================================ +// Initialization +// ============================================================================ + +// Initial render +renderCallbackStatus(); + +// Connect to host +app + .connect() + .then(() => { + logEvent("connected", { success: true }); + + const ctx = app.getHostContext(); + if (ctx) { + handleHostContextChanged(ctx); + } + + renderHostInfo(); + updateCurrentSize(); + + // Auto-resize is enabled by default in App, capture cleanup if we want to toggle + // We'll set it up ourselves since we want toggle control + state.autoResizeCleanup = app.setupSizeChangedNotifications(); + }) + .catch((e) => { + logEvent("error", e); + }); diff --git a/examples/debug-server/tsconfig.json b/examples/debug-server/tsconfig.json new file mode 100644 index 00000000..535267b2 --- /dev/null +++ b/examples/debug-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/examples/debug-server/vite.config.ts b/examples/debug-server/vite.config.ts new file mode 100644 index 00000000..6ff6d997 --- /dev/null +++ b/examples/debug-server/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 891ef2c4..29459298 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -704,6 +704,41 @@ const PREFERRED_INLINE_HEIGHT = 400; // Current display mode let currentDisplayMode: "inline" | "fullscreen" | "pip" = "inline"; +// Default button offset from edge (matches CSS) +const BUTTON_EDGE_OFFSET = 10; + +/** + * Safe area insets from host context. + * Used to offset fixed UI elements on mobile devices with notches/etc. + */ +interface SafeAreaInsets { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Update fixed UI element positions based on safe area insets. + * This keeps the map full-bleed while ensuring controls aren't obscured + * by device notches, status bars, or navigation bars. + */ +function applySafeAreaInsets(insets?: SafeAreaInsets): void { + const btn = document.getElementById("fullscreen-btn"); + if (btn) { + // Offset button from top-right corner, accounting for safe area + btn.style.top = `${BUTTON_EDGE_OFFSET + (insets?.top ?? 0)}px`; + btn.style.right = `${BUTTON_EDGE_OFFSET + (insets?.right ?? 0)}px`; + } + + // Also adjust loading indicator if visible + const loadingEl = document.getElementById("loading"); + if (loadingEl && insets) { + // Center with safe area awareness (only affects vertical position) + loadingEl.style.top = `calc(50% + ${(insets.top - insets.bottom) / 2}px)`; + } +} + // Create App instance with tool capabilities // autoResize: false - we manually send size since map fills its container const app = new App( @@ -814,7 +849,7 @@ app.onteardown = async () => { app.onerror = log.error; -// Listen for host context changes (display mode, theme, etc.) +// Listen for host context changes (display mode, theme, safe area, etc.) app.onhostcontextchanged = (params) => { log.info("Host context changed:", params); @@ -828,6 +863,11 @@ app.onhostcontextchanged = (params) => { if (params.availableDisplayModes) { updateFullscreenButton(); } + + // Update UI element positions if safe area insets changed + if (params.safeAreaInsets) { + applySafeAreaInsets(params.safeAreaInsets); + } }; // Handle initial tool input (bounding box from show-map tool) @@ -971,7 +1011,7 @@ async function initialize() { await app.connect(); log.info("Connected to host"); - // Get initial display mode from host context + // Get initial context from host const context = app.getHostContext(); if (context?.displayMode) { currentDisplayMode = context.displayMode as @@ -981,6 +1021,12 @@ async function initialize() { } log.info("Initial display mode:", currentDisplayMode); + // Apply initial safe area insets for mobile devices + if (context?.safeAreaInsets) { + applySafeAreaInsets(context.safeAreaInsets); + log.info("Applied safe area insets:", context.safeAreaInsets); + } + // Tell host our preferred size for inline mode if (currentDisplayMode === "inline") { app.sendSizeChanged({ height: PREFERRED_INLINE_HEIGHT }); diff --git a/package-lock.json b/package-lock.json index 4930413f..a789865d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -470,6 +470,45 @@ "dev": true, "license": "MIT" }, + "examples/debug-server": { + "name": "@modelcontextprotocol/server-debug", + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.4.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^10.1.0", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, + "examples/debug-server/node_modules/@types/node": { + "version": "22.19.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", + "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "examples/debug-server/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "examples/integration-server": { "version": "1.0.0", "dependencies": { @@ -2420,6 +2459,10 @@ "resolved": "examples/customer-segmentation-server", "link": true }, + "node_modules/@modelcontextprotocol/server-debug": { + "resolved": "examples/debug-server", + "link": true + }, "node_modules/@modelcontextprotocol/server-map": { "resolved": "examples/map-server", "link": true diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 66d5f830..ad7785c9 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -215,6 +215,21 @@ describe("App <-> AppBridge integration", () => { expect(receivedCancellations[0]).toEqual({}); }); + it("tool notifications work with default no-op handlers", async () => { + // Don't set any custom handlers - use defaults + await app.connect(appTransport); + + // These should not throw (default handlers silently accept them) + // Just verify they complete without error + await bridge.sendToolInput({ arguments: {} }); + await bridge.sendToolInputPartial({ arguments: {} }); + await bridge.sendToolResult({ content: [{ type: "text", text: "ok" }] }); + await bridge.sendToolCancelled({}); + + // If we got here without throwing, the test passes + expect(true).toBe(true); + }); + it("setHostContext triggers app.onhostcontextchanged", async () => { const receivedContexts: unknown[] = []; app.onhostcontextchanged = (params) => { diff --git a/src/app.ts b/src/app.ts index e24913e3..7a4c45a7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,7 +17,6 @@ import { PingRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { AppNotification, AppRequest, AppResult } from "./types"; -import { PostMessageTransport } from "./message-transport"; import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, @@ -45,12 +44,22 @@ import { McpUiToolInputPartialNotificationSchema, McpUiToolResultNotification, McpUiToolResultNotificationSchema, + McpUiWidgetStateNotification, + McpUiWidgetStateNotificationSchema, + McpUiUploadFileRequest, + McpUiUploadFileResultSchema, + McpUiGetFileUrlRequest, + McpUiGetFileUrlResultSchema, McpUiRequestDisplayModeRequest, McpUiRequestDisplayModeResultSchema, } from "./types"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { PostMessageTransport } from "./message-transport"; +import { OpenAITransport, isOpenAIEnvironment } from "./openai/transport.js"; export { PostMessageTransport } from "./message-transport"; +export { OpenAITransport, isOpenAIEnvironment } from "./openai/transport"; +export * from "./openai/types"; export * from "./types"; export { applyHostStyleVariables, @@ -107,7 +116,7 @@ export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; * * @see `ProtocolOptions` from @modelcontextprotocol/sdk for inherited options */ -type AppOptions = ProtocolOptions & { +export type AppOptions = ProtocolOptions & { /** * Automatically report size changes to the host using `ResizeObserver`. * @@ -118,6 +127,19 @@ type AppOptions = ProtocolOptions & { * @default true */ autoResize?: boolean; + + /** + * Enable experimental OpenAI compatibility. + * + * When enabled (default), the App will auto-detect the environment: + * - If `window.openai` exists → use OpenAI Apps SDK + * - Otherwise → use MCP Apps protocol via PostMessageTransport + * + * Set to `false` to force MCP-only mode. + * + * @default true + */ + experimentalOAICompatibility?: boolean; }; type RequestHandlerExtra = Parameters< @@ -227,7 +249,10 @@ export class App extends Protocol { constructor( private _appInfo: Implementation, private _capabilities: McpUiAppCapabilities = {}, - private options: AppOptions = { autoResize: true }, + private options: AppOptions = { + autoResize: true, + experimentalOAICompatibility: true, + }, ) { super(options); @@ -236,9 +261,14 @@ export class App extends Protocol { return {}; }); - // Set up default handler to update _hostContext when notifications arrive. - // Users can override this by setting onhostcontextchanged. + // Set up default handlers for notifications. + // Users can override these by setting the corresponding on* properties. this.onhostcontextchanged = () => {}; + this.ontoolinput = () => {}; + this.ontoolinputpartial = () => {}; + this.ontoolresult = () => {}; + this.ontoolcancelled = () => {}; + this.onwidgetstate = () => {}; } /** @@ -484,6 +514,47 @@ export class App extends Protocol { ); } + /** + * Convenience handler for receiving persisted widget state from the host. + * + * Set this property to register a handler that will be called when the host + * delivers previously persisted widget state. This is sent during initialization + * when running in OpenAI mode, allowing apps to hydrate their UI state. + * + * The state can be either a simple object or a StructuredWidgetState with + * modelContent/privateContent/imageIds separation. + * + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. + * + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called with the persisted widget state + * + * @example Hydrate app state from previous session + * ```typescript + * app.onwidgetstate = (params) => { + * if (params.state.selectedId) { + * setSelectedItem(params.state.selectedId); + * } + * if (params.state.privateContent?.viewMode) { + * setViewMode(params.state.privateContent.viewMode); + * } + * }; + * ``` + * + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiWidgetStateNotification} for the notification structure + * @see {@link updateModelContext} for persisting state updates + */ + set onwidgetstate( + callback: (params: McpUiWidgetStateNotification["params"]) => void, + ) { + this.setNotificationHandler(McpUiWidgetStateNotificationSchema, (n) => + callback(n.params), + ); + } + /** * Convenience handler for host context changes (theme, locale, etc.). * @@ -971,6 +1042,90 @@ export class App extends Protocol { }); } + /** + * Upload a file for use in model context. + * + * This allows apps to upload images and other files that can be referenced + * in model context via imageIds in {@link updateModelContext}. + * + * In OpenAI mode, this delegates to window.openai.uploadFile(). + * + * @param file - The File object to upload + * @param options - Request options (timeout, etc.) + * @returns Promise resolving to the file ID + * + * @throws {Error} If file upload is not supported in this environment + * @throws {Error} If the upload fails + * + * @example Upload an image and add to model context + * ```typescript + * const file = new File([imageBlob], "screenshot.png", { type: "image/png" }); + * const { fileId } = await app.uploadFile(file); + * + * // Make the image available to the model + * app.updateModelContext({ + * modelContent: "User uploaded a screenshot", + * imageIds: [fileId], + * }); + * ``` + * + * @see {@link updateModelContext} for using uploaded files in model context + * @see {@link getFileDownloadUrl} for retrieving uploaded files + */ + async uploadFile(file: File, options?: RequestOptions) { + // Convert File to base64 + const arrayBuffer = await file.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + + return this.request( + { + method: "ui/upload-file", + params: { + name: file.name, + mimeType: file.type, + data: base64, + }, + }, + McpUiUploadFileResultSchema, + options, + ); + } + + /** + * Get a temporary download URL for a previously uploaded file. + * + * In OpenAI mode, this delegates to window.openai.getFileDownloadUrl(). + * + * @param params - The file ID from a previous upload + * @param options - Request options (timeout, etc.) + * @returns Promise resolving to the download URL + * + * @throws {Error} If file URL retrieval is not supported in this environment + * @throws {Error} If the file ID is invalid or expired + * + * @example Download a previously uploaded file + * ```typescript + * const { url } = await app.getFileDownloadUrl({ fileId }); + * const response = await fetch(url); + * const blob = await response.blob(); + * ``` + * + * @see {@link uploadFile} for uploading files + */ + getFileDownloadUrl( + params: McpUiGetFileUrlRequest["params"], + options?: RequestOptions, + ) { + return this.request( + { + method: "ui/get-file-url", + params, + }, + McpUiGetFileUrlResultSchema, + options, + ); + } + /** * Set up automatic size change notifications using ResizeObserver. * @@ -1047,50 +1202,73 @@ export class App extends Protocol { return () => resizeObserver.disconnect(); } + /** + * Create the default transport based on detected platform. + * @internal + */ + private createDefaultTransport(): Transport { + const experimentalOAI = this.options?.experimentalOAICompatibility ?? true; + if (experimentalOAI && isOpenAIEnvironment()) { + return new OpenAITransport(); + } + return new PostMessageTransport(window.parent, window.parent); + } + /** * Establish connection with the host and perform initialization handshake. * * This method performs the following steps: - * 1. Connects the transport layer - * 2. Sends `ui/initialize` request with app info and capabilities - * 3. Receives host capabilities and context in response - * 4. Sends `ui/notifications/initialized` notification - * 5. Sets up auto-resize using {@link setupSizeChangedNotifications} if enabled (default) + * 1. Auto-detects platform if no transport is provided + * 2. Connects the transport layer + * 3. Sends `ui/initialize` request with app info and capabilities + * 4. Receives host capabilities and context in response + * 5. Sends `ui/notifications/initialized` notification + * 6. Sets up auto-resize using {@link setupSizeChangedNotifications} if enabled (default) + * 7. For OpenAI mode: delivers initial tool input/result from window.openai * * If initialization fails, the connection is automatically closed and an error * is thrown. * - * @param transport - Transport layer (typically {@link PostMessageTransport}) + * @param transport - Optional transport layer. If not provided, auto-detects + * based on the `platform` option: + * - `'openai'` or `window.openai` exists → uses {@link OpenAITransport} + * - `'mcp'` or no `window.openai` → uses {@link PostMessageTransport} * @param options - Request options for the initialize request * * @throws {Error} If initialization fails or connection is lost * - * @example Connect with PostMessageTransport + * @example Auto-detect platform (recommended) * ```typescript * 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); - * } + * // Auto-detects: OpenAI if window.openai exists, MCP otherwise + * await app.connect(); + * ``` + * + * @example Explicit MCP transport + * ```typescript + * await app.connect(new PostMessageTransport(window.parent)); + * ``` + * + * @example Explicit OpenAI transport + * ```typescript + * await app.connect(new OpenAITransport()); * ``` * * @see {@link McpUiInitializeRequest} for the initialization request structure * @see {@link McpUiInitializedNotification} for the initialized notification - * @see {@link PostMessageTransport} for the typical transport implementation + * @see {@link PostMessageTransport} for MCP-compatible hosts + * @see {@link OpenAITransport} for OpenAI/ChatGPT hosts */ override async connect( - transport: Transport = new PostMessageTransport( - window.parent, - window.parent, - ), + transport?: Transport, options?: RequestOptions, ): Promise { + transport ??= this.createDefaultTransport(); + await super.connect(transport); try { @@ -1122,6 +1300,11 @@ export class App extends Protocol { if (this.options?.autoResize) { this.setupSizeChangedNotifications(); } + + // For OpenAI mode: deliver initial state from window.openai + if (transport instanceof OpenAITransport) { + transport.deliverInitialState(); + } } catch (error) { // Disconnect if initialization fails. void this.close(); diff --git a/src/generated/schema.json b/src/generated/schema.json index e17767d9..9212f78f 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -46,6 +46,41 @@ ], "description": "Display mode for UI presentation." }, + "McpUiGetFileUrlRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/get-file-url" + }, + "params": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "The file ID from a previous upload" + } + }, + "required": ["fileId"], + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false + }, + "McpUiGetFileUrlResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Temporary download URL for the file" + } + }, + "required": ["url"], + "additionalProperties": {} + }, "McpUiHostCapabilities": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -5349,6 +5384,70 @@ ], "description": "Tool visibility scope - who can access the tool." }, + "McpUiUpdateModelContextNotification": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/notifications/update-model-context" + }, + "params": { + "type": "object", + "properties": { + "modelContent": { + "anyOf": [ + { + "description": "Text or JSON the model should see for follow-up reasoning.\nKeep focused and under 4k tokens.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + { + "type": "null" + } + ] + }, + "privateContent": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "UI-only state the model should NOT see.\nUse for ephemeral UI details like current view, filters, selections." + } + }, + { + "type": "null" + } + ], + "description": "UI-only state the model should NOT see.\nUse for ephemeral UI details like current view, filters, selections." + }, + "imageIds": { + "description": "File IDs for images the model should reason about.\nUse file IDs from uploadFile() or received as file params.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false + }, "McpUiUpdateModelContextRequest": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -5693,6 +5792,78 @@ }, "required": ["method", "params"], "additionalProperties": false + }, + "McpUiUploadFileRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/upload-file" + }, + "params": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "File name with extension" + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file" + }, + "data": { + "type": "string", + "description": "Base64-encoded file data" + } + }, + "required": ["name", "mimeType", "data"], + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false + }, + "McpUiUploadFileResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "The file ID to use in imageIds for model context" + } + }, + "required": ["fileId"], + "additionalProperties": {} + }, + "McpUiWidgetStateNotification": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/notifications/widget-state" + }, + "params": { + "type": "object", + "properties": { + "state": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "The persisted widget state from previous interaction." + }, + "description": "The persisted widget state from previous interaction." + } + }, + "required": ["state"], + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false } } } diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 727c28d2..6cb3e961 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -67,6 +67,14 @@ export type McpUiToolCancelledNotificationSchemaInferredType = z.infer< typeof generated.McpUiToolCancelledNotificationSchema >; +export type McpUiWidgetStateNotificationSchemaInferredType = z.infer< + typeof generated.McpUiWidgetStateNotificationSchema +>; + +export type McpUiUpdateModelContextNotificationSchemaInferredType = z.infer< + typeof generated.McpUiUpdateModelContextNotificationSchema +>; + export type McpUiHostCssSchemaInferredType = z.infer< typeof generated.McpUiHostCssSchema >; @@ -119,6 +127,22 @@ export type McpUiToolMetaSchemaInferredType = z.infer< typeof generated.McpUiToolMetaSchema >; +export type McpUiUploadFileRequestSchemaInferredType = z.infer< + typeof generated.McpUiUploadFileRequestSchema +>; + +export type McpUiUploadFileResultSchemaInferredType = z.infer< + typeof generated.McpUiUploadFileResultSchema +>; + +export type McpUiGetFileUrlRequestSchemaInferredType = z.infer< + typeof generated.McpUiGetFileUrlRequestSchema +>; + +export type McpUiGetFileUrlResultSchemaInferredType = z.infer< + typeof generated.McpUiGetFileUrlResultSchema +>; + export type McpUiMessageRequestSchemaInferredType = z.infer< typeof generated.McpUiMessageRequestSchema >; @@ -215,6 +239,18 @@ expectType( expectType( {} as spec.McpUiToolCancelledNotification, ); +expectType( + {} as McpUiWidgetStateNotificationSchemaInferredType, +); +expectType( + {} as spec.McpUiWidgetStateNotification, +); +expectType( + {} as McpUiUpdateModelContextNotificationSchemaInferredType, +); +expectType( + {} as spec.McpUiUpdateModelContextNotification, +); expectType({} as McpUiHostCssSchemaInferredType); expectType({} as spec.McpUiHostCss); expectType({} as McpUiHostStylesSchemaInferredType); @@ -277,6 +313,30 @@ expectType( ); expectType({} as McpUiToolMetaSchemaInferredType); expectType({} as spec.McpUiToolMeta); +expectType( + {} as McpUiUploadFileRequestSchemaInferredType, +); +expectType( + {} as spec.McpUiUploadFileRequest, +); +expectType( + {} as McpUiUploadFileResultSchemaInferredType, +); +expectType( + {} as spec.McpUiUploadFileResult, +); +expectType( + {} as McpUiGetFileUrlRequestSchemaInferredType, +); +expectType( + {} as spec.McpUiGetFileUrlRequest, +); +expectType( + {} as McpUiGetFileUrlResultSchemaInferredType, +); +expectType( + {} as spec.McpUiGetFileUrlResult, +); expectType( {} as McpUiMessageRequestSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 32277d23..5da5a3f9 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -315,6 +315,85 @@ export const McpUiToolCancelledNotificationSchema = z.object({ }), }); +/** + * @description Notification containing persisted widget state (Host -> Guest UI). + * + * This notification delivers previously persisted UI state on widget load. + * In OpenAI mode, this comes from window.openai.widgetState. Apps use this + * to hydrate their UI state from previous sessions. + * + * The state can be either a simple object or a StructuredWidgetState with + * separate modelContent/privateContent/imageIds fields. + */ +export const McpUiWidgetStateNotificationSchema = z.object({ + method: z.literal("ui/notifications/widget-state"), + params: z.object({ + /** @description The persisted widget state from previous interaction. */ + state: z + .record( + z.string(), + z + .unknown() + .describe("The persisted widget state from previous interaction."), + ) + .describe("The persisted widget state from previous interaction."), + }), +}); + +/** + * @description Notification to update model context and persist widget state (Guest UI -> Host). + * + * This notification allows apps to update what the model sees for follow-up turns + * and persist UI state. In OpenAI mode, this calls window.openai.setWidgetState(). + * + * Use the structured format with modelContent/privateContent/imageIds for fine-grained + * control over what the model sees vs. what stays private to the UI. + */ +export const McpUiUpdateModelContextNotificationSchema = z.object({ + method: z.literal("ui/notifications/update-model-context"), + params: z.object({ + /** + * @description Text or JSON the model should see for follow-up reasoning. + * Keep focused and under 4k tokens. + */ + modelContent: z + .union([z.string(), z.record(z.string(), z.unknown())]) + .optional() + .describe( + "Text or JSON the model should see for follow-up reasoning.\nKeep focused and under 4k tokens.", + ) + .nullable(), + /** + * @description UI-only state the model should NOT see. + * Use for ephemeral UI details like current view, filters, selections. + */ + privateContent: z + .record( + z.string(), + z + .unknown() + .describe( + "UI-only state the model should NOT see.\nUse for ephemeral UI details like current view, filters, selections.", + ), + ) + .optional() + .nullable() + .describe( + "UI-only state the model should NOT see.\nUse for ephemeral UI details like current view, filters, selections.", + ), + /** + * @description File IDs for images the model should reason about. + * Use file IDs from uploadFile() or received as file params. + */ + imageIds: z + .array(z.string()) + .optional() + .describe( + "File IDs for images the model should reason about.\nUse file IDs from uploadFile() or received as file params.", + ), + }), +}); + /** * @description CSS blocks that can be injected by apps. */ @@ -565,6 +644,63 @@ export const McpUiToolMetaSchema = z.object({ ), }); +/** + * @description Request to upload a file for use in model context. + * + * This allows apps to upload images and other files that can be referenced + * in model context via imageIds in updateModelContext. + * + * @see {@link app.App.uploadFile} for the method that sends this request + */ +export const McpUiUploadFileRequestSchema = z.object({ + method: z.literal("ui/upload-file"), + params: z.object({ + /** @description File name with extension */ + name: z.string().describe("File name with extension"), + /** @description MIME type of the file */ + mimeType: z.string().describe("MIME type of the file"), + /** @description Base64-encoded file data */ + data: z.string().describe("Base64-encoded file data"), + }), +}); + +/** + * @description Result from uploading a file. + * @see {@link McpUiUploadFileRequest} + */ +export const McpUiUploadFileResultSchema = z + .object({ + /** @description The file ID to use in imageIds for model context */ + fileId: z + .string() + .describe("The file ID to use in imageIds for model context"), + }) + .passthrough(); + +/** + * @description Request to get a download URL for a previously uploaded file. + * + * @see {@link app.App.getFileDownloadUrl} for the method that sends this request + */ +export const McpUiGetFileUrlRequestSchema = z.object({ + method: z.literal("ui/get-file-url"), + params: z.object({ + /** @description The file ID from a previous upload */ + fileId: z.string().describe("The file ID from a previous upload"), + }), +}); + +/** + * @description Result from getting a file download URL. + * @see {@link McpUiGetFileUrlRequest} + */ +export const McpUiGetFileUrlResultSchema = z + .object({ + /** @description Temporary download URL for the file */ + url: z.string().describe("Temporary download URL for the file"), + }) + .passthrough(); + /** * @description Request to send a message to the host's chat interface. * @see {@link app!App.sendMessage} for the method that sends this request diff --git a/src/openai/transport.test.ts b/src/openai/transport.test.ts new file mode 100644 index 00000000..962a0c6c --- /dev/null +++ b/src/openai/transport.test.ts @@ -0,0 +1,496 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; +import { OpenAITransport, isOpenAIEnvironment } from "./transport"; +import type { OpenAIGlobal, WindowWithOpenAI } from "./types"; + +describe("isOpenAIEnvironment", () => { + const originalWindow = globalThis.window; + + afterEach(() => { + // Restore original window + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = originalWindow; + } + }); + + test("returns false when window is undefined", () => { + delete (globalThis as { window?: unknown }).window; + expect(isOpenAIEnvironment()).toBe(false); + }); + + test("returns false when window.openai is undefined", () => { + (globalThis as { window?: unknown }).window = {}; + expect(isOpenAIEnvironment()).toBe(false); + }); + + test("returns true when window.openai is an object", () => { + (globalThis as { window?: unknown }).window = { + openai: {}, + }; + expect(isOpenAIEnvironment()).toBe(true); + }); +}); + +describe("OpenAITransport", () => { + let mockOpenAI: OpenAIGlobal; + + beforeEach(() => { + mockOpenAI = { + theme: "dark", + locale: "en-US", + displayMode: "inline", + maxHeight: 600, + toolInput: { location: "Tokyo" }, + toolOutput: { temperature: 22 }, + callTool: mock(() => + Promise.resolve({ content: { result: "success" } }), + ) as unknown as OpenAIGlobal["callTool"], + sendFollowUpMessage: mock(() => + Promise.resolve(), + ) as unknown as OpenAIGlobal["sendFollowUpMessage"], + openExternal: mock(() => + Promise.resolve(), + ) as unknown as OpenAIGlobal["openExternal"], + notifyIntrinsicHeight: mock( + () => {}, + ) as unknown as OpenAIGlobal["notifyIntrinsicHeight"], + }; + + (globalThis as { window?: unknown }).window = { + openai: mockOpenAI, + }; + }); + + afterEach(() => { + delete (globalThis as { window?: unknown }).window; + }); + + test("throws when window.openai is not available", () => { + delete (globalThis as { window?: unknown }).window; + expect(() => new OpenAITransport()).toThrow( + "OpenAITransport requires window.openai", + ); + }); + + test("constructs successfully when window.openai is available", () => { + const transport = new OpenAITransport(); + expect(transport).toBeDefined(); + }); + + test("start() completes without error", async () => { + const transport = new OpenAITransport(); + await expect(transport.start()).resolves.toBeUndefined(); + }); + + test("close() calls onclose callback", async () => { + const transport = new OpenAITransport(); + const onclose = mock(() => {}); + transport.onclose = onclose; + + await transport.close(); + + expect(onclose).toHaveBeenCalled(); + }); + + describe("ui/initialize request", () => { + test("returns synthesized host info from window.openai", async () => { + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { + protocolVersion: "2025-11-21", + appInfo: { name: "TestApp", version: "1.0.0" }, + appCapabilities: {}, + }, + }); + + // Wait for microtask to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 1, + result: { + hostInfo: { name: "ChatGPT", version: "1.0.0" }, + hostContext: { + theme: "dark", + locale: "en-US", + displayMode: "inline", + }, + }, + }); + }); + + test("dynamically reports capabilities based on available methods", async () => { + // Remove callTool to test dynamic detection + delete mockOpenAI.callTool; + + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { + protocolVersion: "2025-11-21", + appInfo: { name: "TestApp", version: "1.0.0" }, + appCapabilities: {}, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const result = (response as { result: { hostCapabilities: unknown } }) + .result.hostCapabilities as Record; + + // serverTools should NOT be present since callTool is missing + expect(result.serverTools).toBeUndefined(); + // openLinks should be present since openExternal exists + expect(result.openLinks).toBeDefined(); + // logging is always available + expect(result.logging).toBeDefined(); + }); + + test("includes availableDisplayModes when requestDisplayMode is available", async () => { + mockOpenAI.requestDisplayMode = mock(() => + Promise.resolve(), + ) as unknown as OpenAIGlobal["requestDisplayMode"]; + + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { + protocolVersion: "2025-11-21", + appInfo: { name: "TestApp", version: "1.0.0" }, + appCapabilities: {}, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 1, + result: { + hostContext: { + availableDisplayModes: ["inline", "pip", "fullscreen"], + }, + }, + }); + }); + }); + + describe("tools/call request", () => { + test("delegates to window.openai.callTool()", async () => { + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "get_weather", + arguments: { location: "Tokyo" }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockOpenAI.callTool).toHaveBeenCalledWith("get_weather", { + location: "Tokyo", + }); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 2, + result: expect.any(Object), + }); + }); + + test("returns error when callTool is not available", async () => { + delete mockOpenAI.callTool; + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "test_tool" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 3, + error: { + code: -32601, + message: expect.stringContaining("not supported"), + }, + }); + }); + }); + + describe("ui/message request", () => { + test("delegates to window.openai.sendFollowUpMessage()", async () => { + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 4, + method: "ui/message", + params: { + role: "user", + content: [{ type: "text", text: "Hello!" }], + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockOpenAI.sendFollowUpMessage).toHaveBeenCalledWith({ + prompt: "Hello!", + }); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 4, + result: {}, + }); + }); + }); + + describe("ui/open-link request", () => { + test("delegates to window.openai.openExternal()", async () => { + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 5, + method: "ui/open-link", + params: { url: "https://example.com" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockOpenAI.openExternal).toHaveBeenCalledWith({ + href: "https://example.com", + }); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 5, + result: {}, + }); + }); + }); + + describe("ui/request-display-mode request", () => { + test("delegates to window.openai.requestDisplayMode()", async () => { + mockOpenAI.requestDisplayMode = mock(() => + Promise.resolve(), + ) as unknown as OpenAIGlobal["requestDisplayMode"]; + + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 6, + method: "ui/request-display-mode", + params: { mode: "fullscreen" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockOpenAI.requestDisplayMode).toHaveBeenCalledWith({ + mode: "fullscreen", + }); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 6, + result: { mode: "fullscreen" }, + }); + }); + }); + + describe("ui/notifications/size-changed notification", () => { + test("delegates to window.openai.notifyIntrinsicHeight()", async () => { + const transport = new OpenAITransport(); + + await transport.send({ + jsonrpc: "2.0", + method: "ui/notifications/size-changed", + params: { width: 400, height: 300 }, + }); + + expect(mockOpenAI.notifyIntrinsicHeight).toHaveBeenCalledWith(300); + }); + }); + + describe("deliverInitialState", () => { + test("delivers tool input notification", async () => { + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolInputNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-input", + ); + expect(toolInputNotification).toMatchObject({ + jsonrpc: "2.0", + method: "ui/notifications/tool-input", + params: { arguments: { location: "Tokyo" } }, + }); + }); + + test("delivers tool result notification", async () => { + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolResultNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-result", + ); + expect(toolResultNotification).toBeDefined(); + }); + + test("includes _meta from toolResponseMetadata in tool result", async () => { + mockOpenAI.toolResponseMetadata = { widgetId: "abc123", version: 2 }; + + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolResultNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-result", + ); + expect(toolResultNotification).toMatchObject({ + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: { + _meta: { widgetId: "abc123", version: 2 }, + }, + }); + }); + + test("converts null _meta to undefined in tool result", async () => { + // Simulate null being set (e.g., from JSON parsing where null is valid) + ( + mockOpenAI as unknown as { toolResponseMetadata: null } + ).toolResponseMetadata = null; + + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolResultNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-result", + ) as { params?: { _meta?: unknown } } | undefined; + expect(toolResultNotification).toBeDefined(); + // _meta should be undefined, not null (SDK rejects null) + expect(toolResultNotification?.params?._meta).toBeUndefined(); + }); + + test("does not deliver tool-result when toolOutput is null", async () => { + // Simulate null being set (e.g., from JSON parsing) + (mockOpenAI as unknown as { toolOutput: null }).toolOutput = null; + + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolResultNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-result", + ); + // Should NOT deliver tool-result when toolOutput is null + expect(toolResultNotification).toBeUndefined(); + }); + + test("does not deliver notifications when data is missing", async () => { + delete mockOpenAI.toolInput; + delete mockOpenAI.toolOutput; + + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(messages).toHaveLength(0); + }); + }); +}); diff --git a/src/openai/transport.ts b/src/openai/transport.ts new file mode 100644 index 00000000..f4c9c6aa --- /dev/null +++ b/src/openai/transport.ts @@ -0,0 +1,614 @@ +/** + * Transport adapter for OpenAI Apps SDK (window.openai) compatibility. + * + * This transport allows MCP Apps to run in OpenAI's ChatGPT environment by + * translating between the MCP Apps protocol and the OpenAI Apps SDK APIs. + * + * @see https://developers.openai.com/apps-sdk/build/chatgpt-ui/ + */ + +import { + JSONRPCMessage, + JSONRPCRequest, + JSONRPCNotification, + RequestId, +} from "@modelcontextprotocol/sdk/types.js"; +import { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.js"; +import { OpenAIGlobal, getOpenAIGlobal, isOpenAIEnvironment } from "./types.js"; +import { LATEST_PROTOCOL_VERSION, McpUiHostContext } from "../spec.types.js"; + +/** + * JSON-RPC success response message. + * @internal + */ +interface JSONRPCSuccessResponse { + jsonrpc: "2.0"; + id: RequestId; + result: Record; +} + +/** + * JSON-RPC error response message. + * @internal + */ +interface JSONRPCErrorResponse { + jsonrpc: "2.0"; + id: RequestId; + error: { code: number; message: string; data?: unknown }; +} + +/** + * Check if a message is a JSON-RPC request (has method and id). + */ +function isRequest(message: JSONRPCMessage): message is JSONRPCRequest { + return "method" in message && "id" in message; +} + +/** + * Check if a message is a JSON-RPC notification (has method but no id). + */ +function isNotification( + message: JSONRPCMessage, +): message is JSONRPCNotification { + return "method" in message && !("id" in message); +} + +/** + * Transport implementation that bridges MCP Apps protocol to OpenAI Apps SDK. + * + * This transport enables MCP Apps to run seamlessly in ChatGPT by: + * - Synthesizing initialization responses from window.openai properties + * - Mapping tool calls to window.openai.callTool() + * - Mapping messages to window.openai.sendFollowUpMessage() + * - Mapping link opens to window.openai.openExternal() + * - Reporting size changes via window.openai.notifyIntrinsicHeight() + * + * ## Usage + * + * Typically you don't create this transport directly. The App will create + * it automatically when `experimentalOAICompatibility` is enabled (default) + * and `window.openai` is detected. + * + * ```typescript + * import { App } from '@modelcontextprotocol/ext-apps'; + * + * const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + * await app.connect(); // Auto-detects OpenAI environment + * ``` + * + * ## Manual Usage + * + * For advanced use cases, you can create the transport directly: + * + * ```typescript + * import { App, OpenAITransport } from '@modelcontextprotocol/ext-apps'; + * + * const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + * await app.connect(new OpenAITransport()); + * ``` + * + * @see {@link App.connect} for automatic transport selection + * @see {@link PostMessageTransport} for MCP-compatible hosts + */ +export class OpenAITransport implements Transport { + private openai: OpenAIGlobal; + private _closed = false; + + /** + * Create a new OpenAITransport. + * + * @throws {Error} If window.openai is not available + * + * @example + * ```typescript + * if (isOpenAIEnvironment()) { + * const transport = new OpenAITransport(); + * await app.connect(transport); + * } + * ``` + */ + constructor() { + const openai = getOpenAIGlobal(); + if (!openai) { + throw new Error( + "OpenAITransport requires window.openai to be available. " + + "This transport should only be used in OpenAI/ChatGPT environments.", + ); + } + this.openai = openai; + } + + /** + * Begin listening for messages. + * + * In OpenAI mode, there's no event-based message flow to start. + * The data is pre-populated in window.openai properties. + */ + async start(): Promise { + // Nothing to do - window.openai is already available and populated + } + + /** + * Send a JSON-RPC message. + * + * Requests are handled by mapping to window.openai methods. + * Notifications are handled for size changes; others are no-ops. + * + * @param message - JSON-RPC message to send + * @param _options - Send options (unused) + */ + async send( + message: JSONRPCMessage, + _options?: TransportSendOptions, + ): Promise { + if (this._closed) { + throw new Error("Transport is closed"); + } + + if (isRequest(message)) { + // Handle requests - map to window.openai methods and synthesize responses + const response = await this.handleRequest(message); + // Deliver response asynchronously to maintain message ordering + queueMicrotask(() => this.onmessage?.(response)); + } else if (isNotification(message)) { + // Handle notifications + this.handleNotification(message); + } + // Responses are ignored - we don't receive requests from OpenAI + } + + /** + * Handle an outgoing JSON-RPC request by mapping to window.openai. + */ + private async handleRequest( + request: JSONRPCRequest, + ): Promise { + const { method, id, params } = request; + + try { + switch (method) { + case "ui/initialize": + return this.handleInitialize(id); + + case "tools/call": + return await this.handleToolCall( + id, + params as { name: string; arguments?: Record }, + ); + + case "ui/message": + return await this.handleMessage( + id, + params as { role: string; content: unknown[] }, + ); + + case "ui/open-link": + return await this.handleOpenLink(id, params as { url: string }); + + case "ui/request-display-mode": + return await this.handleRequestDisplayMode( + id, + params as { mode: string }, + ); + + case "ping": + return this.createSuccessResponse(id, {}); + + default: + return this.createErrorResponse( + id, + -32601, + `Method not supported in OpenAI mode: ${method}`, + ); + } + } catch (error) { + return this.createErrorResponse( + id, + -32603, + error instanceof Error ? error.message : String(error), + ); + } + } + + /** + * Handle ui/initialize request by synthesizing response from window.openai. + */ + private handleInitialize(id: RequestId): JSONRPCSuccessResponse { + // Safely extract userAgent - could be string or object + let userAgent: string | undefined; + if (typeof this.openai.userAgent === "string") { + userAgent = this.openai.userAgent; + } else if ( + this.openai.userAgent && + typeof this.openai.userAgent === "object" + ) { + userAgent = JSON.stringify(this.openai.userAgent); + } + + // Safely extract safeAreaInsets - only include if all values are present + let safeAreaInsets: McpUiHostContext["safeAreaInsets"]; + const sa = this.openai.safeArea; + if ( + sa && + typeof sa.top === "number" && + typeof sa.right === "number" && + typeof sa.bottom === "number" && + typeof sa.left === "number" + ) { + safeAreaInsets = sa; + } + + const hostContext: McpUiHostContext = { + theme: this.openai.theme, + locale: this.openai.locale, + displayMode: this.openai.displayMode, + // If requestDisplayMode is available, ChatGPT supports all three modes + availableDisplayModes: this.openai.requestDisplayMode + ? ["inline", "pip", "fullscreen"] + : undefined, + viewport: this.openai.maxHeight + ? { width: 0, height: 0, maxHeight: this.openai.maxHeight } + : undefined, + safeAreaInsets, + userAgent, + }; + + // Dynamically determine capabilities based on what window.openai supports + const hostCapabilities: Record = { + // Logging is always available (we map to console.log) + logging: {}, + }; + + // Only advertise serverTools if callTool is available + if (this.openai.callTool) { + hostCapabilities.serverTools = {}; + } + + // Only advertise openLinks if openExternal is available + if (this.openai.openExternal) { + hostCapabilities.openLinks = {}; + } + + return this.createSuccessResponse(id, { + protocolVersion: LATEST_PROTOCOL_VERSION, + hostInfo: { + name: "ChatGPT", + version: "1.0.0", + }, + hostCapabilities, + hostContext, + }); + } + + /** + * Handle tools/call request by delegating to window.openai.callTool(). + */ + private async handleToolCall( + id: RequestId, + params: { name: string; arguments?: Record }, + ): Promise { + if (!this.openai.callTool) { + return this.createErrorResponse( + id, + -32601, + "Tool calls are not supported in this OpenAI environment", + ); + } + + const result = await this.openai.callTool(params.name, params.arguments); + + // Handle different response formats from OpenAI + // Could be { content: [...] }, { structuredContent: ... }, or the raw data + let content: { type: string; text: string }[]; + if (Array.isArray(result.content)) { + // Clean up content items - remove null values for annotations/_meta + content = result.content.map((item: unknown) => { + if ( + typeof item === "object" && + item !== null && + "type" in item && + "text" in item + ) { + const typedItem = item as { + type: string; + text: string; + annotations?: unknown; + _meta?: unknown; + }; + return { type: typedItem.type, text: typedItem.text }; + } + return { type: "text", text: JSON.stringify(item) }; + }); + } else if (result.structuredContent !== undefined) { + content = [ + { type: "text", text: JSON.stringify(result.structuredContent) }, + ]; + } else if (result.content !== undefined) { + content = [{ type: "text", text: JSON.stringify(result.content) }]; + } else { + // The result itself might be the structured content + content = [{ type: "text", text: JSON.stringify(result) }]; + } + + return this.createSuccessResponse(id, { + content, + isError: result.isError, + }); + } + + /** + * Handle ui/message request by delegating to window.openai.sendFollowUpMessage(). + */ + private async handleMessage( + id: RequestId, + params: { role: string; content: unknown[] }, + ): Promise { + if (!this.openai.sendFollowUpMessage) { + return this.createErrorResponse( + id, + -32601, + "Sending messages is not supported in this OpenAI environment", + ); + } + + // Extract text content from the message + const textContent = params.content + .filter( + (c): c is { type: "text"; text: string } => + typeof c === "object" && + c !== null && + (c as { type?: string }).type === "text", + ) + .map((c) => c.text) + .join("\n"); + + await this.openai.sendFollowUpMessage({ prompt: textContent }); + + return this.createSuccessResponse(id, {}); + } + + /** + * Handle ui/open-link request by delegating to window.openai.openExternal(). + */ + private async handleOpenLink( + id: RequestId, + params: { url: string }, + ): Promise { + if (!this.openai.openExternal) { + return this.createErrorResponse( + id, + -32601, + "Opening external links is not supported in this OpenAI environment", + ); + } + + await this.openai.openExternal({ href: params.url }); + + return this.createSuccessResponse(id, {}); + } + + /** + * Handle ui/request-display-mode by delegating to window.openai.requestDisplayMode(). + */ + private async handleRequestDisplayMode( + id: RequestId, + params: { mode: string }, + ): Promise { + if (!this.openai.requestDisplayMode) { + return this.createErrorResponse( + id, + -32601, + "Display mode changes are not supported in this OpenAI environment", + ); + } + + const mode = params.mode as "inline" | "pip" | "fullscreen"; + await this.openai.requestDisplayMode({ mode }); + + return this.createSuccessResponse(id, { mode }); + } + + /** + * Handle an outgoing notification. + */ + private handleNotification(notification: JSONRPCNotification): void { + const { method, params } = notification; + + switch (method) { + case "ui/notifications/size-changed": + this.handleSizeChanged(params as { width?: number; height?: number }); + break; + + case "ui/notifications/initialized": + // No-op - OpenAI doesn't need this notification + break; + + case "notifications/message": + // Log messages - could be sent to console in OpenAI mode + console.log("[MCP App Log]", params); + break; + + default: + // Ignore unknown notifications + break; + } + } + + /** + * Handle size changed notification by calling window.openai.notifyIntrinsicHeight(). + */ + private handleSizeChanged(params: { width?: number; height?: number }): void { + if (this.openai.notifyIntrinsicHeight && params.height !== undefined) { + this.openai.notifyIntrinsicHeight(params.height); + } + } + + /** + * Create a success JSON-RPC response. + */ + private createSuccessResponse( + id: RequestId, + result: Record, + ): JSONRPCSuccessResponse { + return { + jsonrpc: "2.0", + id, + result, + }; + } + + /** + * Create an error JSON-RPC response. + */ + private createErrorResponse( + id: RequestId, + code: number, + message: string, + ): JSONRPCErrorResponse { + return { + jsonrpc: "2.0", + id, + error: { code, message }, + }; + } + + /** + * Deliver initial tool input and result notifications. + * + * Called by App after connection to deliver pre-populated data from + * window.openai as notifications that the app's handlers expect. + * + * @internal + */ + deliverInitialState(): void { + // Deliver tool input if available + if (this.openai.toolInput !== undefined) { + queueMicrotask(() => { + this.onmessage?.({ + jsonrpc: "2.0", + method: "ui/notifications/tool-input", + params: { arguments: this.openai.toolInput }, + } as JSONRPCNotification); + }); + } + + // Deliver tool output if available (check for both null and undefined) + if (this.openai.toolOutput != null) { + queueMicrotask(() => { + // Normalize toolOutput to MCP CallToolResult format + let content: Array<{ + type: string; + text?: string; + [key: string]: unknown; + }>; + let structuredContent: Record | undefined; + const output = this.openai.toolOutput; + + // Check if output is already a CallToolResult-like object with content/structuredContent + if ( + typeof output === "object" && + output !== null && + ("content" in output || "structuredContent" in output) + ) { + const result = output as { + content?: unknown; + structuredContent?: Record; + }; + // Prefer structuredContent if available + if (result.structuredContent !== undefined) { + structuredContent = result.structuredContent; + // Generate content from structuredContent if not provided + content = Array.isArray(result.content) + ? result.content + : [ + { + type: "text", + text: JSON.stringify(result.structuredContent), + }, + ]; + } else if (Array.isArray(result.content)) { + content = result.content; + } else { + content = [{ type: "text", text: JSON.stringify(output) }]; + } + } else if (Array.isArray(output)) { + // Already an array of content blocks + content = output; + } else if ( + typeof output === "object" && + output !== null && + "type" in output && + typeof (output as { type: unknown }).type === "string" + ) { + // Single content block object like {type: "text", text: "..."} + content = [output as { type: string; text?: string }]; + } else if ( + typeof output === "object" && + output !== null && + "text" in output && + typeof (output as { text: unknown }).text === "string" + ) { + // Object with just text field - treat as text content + content = [{ type: "text", text: (output as { text: string }).text }]; + } else if (typeof output === "object" && output !== null) { + // Plain object - use as structuredContent and generate text content + structuredContent = output as Record; + content = [{ type: "text", text: JSON.stringify(output) }]; + } else { + // Unknown shape - stringify it + content = [{ type: "text", text: JSON.stringify(output) }]; + } + + this.onmessage?.({ + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: { + content, + structuredContent, + // Include _meta from toolResponseMetadata if available (use undefined not null) + _meta: this.openai.toolResponseMetadata ?? undefined, + }, + } as JSONRPCNotification); + }); + } + } + + /** + * Close the transport. + */ + async close(): Promise { + this._closed = true; + this.onclose?.(); + } + + /** + * Called when the transport is closed. + */ + onclose?: () => void; + + /** + * Called when an error occurs. + */ + onerror?: (error: Error) => void; + + /** + * Called when a message is received. + */ + onmessage?: (message: JSONRPCMessage) => void; + + /** + * Session identifier (unused in OpenAI mode). + */ + sessionId?: string; + + /** + * Callback to set the negotiated protocol version. + */ + setProtocolVersion?: (version: string) => void; +} + +// Re-export utility functions +export { isOpenAIEnvironment, getOpenAIGlobal }; diff --git a/src/openai/types.ts b/src/openai/types.ts new file mode 100644 index 00000000..435823f9 --- /dev/null +++ b/src/openai/types.ts @@ -0,0 +1,244 @@ +/** + * Type definitions for the OpenAI Apps SDK's window.openai object. + * + * These types describe the API surface that ChatGPT injects into widget iframes. + * When running in OpenAI mode, the {@link OpenAITransport} uses these APIs to + * communicate with the ChatGPT host. + * + * @see https://developers.openai.com/apps-sdk/build/chatgpt-ui/ + */ + +/** + * Display mode for the widget in ChatGPT. + */ +export type OpenAIDisplayMode = "inline" | "pip" | "fullscreen"; + +/** + * Theme setting from the ChatGPT host. + */ +export type OpenAITheme = "light" | "dark"; + +/** + * Safe area insets for the widget viewport. + */ +export interface OpenAISafeArea { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Result of a tool call via window.openai.callTool(). + * + * Note: The exact return type isn't fully documented by OpenAI. + * Based on observed behavior, it returns structured content. + */ +export interface OpenAIToolCallResult { + /** Structured content from the tool (may be any shape) */ + structuredContent?: unknown; + /** Legacy content field (for compatibility) */ + content?: unknown; + /** Whether the tool call resulted in an error */ + isError?: boolean; +} + +/** + * The window.openai object injected by ChatGPT into widget iframes. + * + * This interface describes the API surface available to widgets running + * in the ChatGPT environment. + */ +export interface OpenAIGlobal { + // ───────────────────────────────────────────────────────────────────────── + // State & Data Properties + // ───────────────────────────────────────────────────────────────────────── + + /** + * Tool arguments passed when invoking the tool. + * Pre-populated when the widget loads. + */ + toolInput?: Record; + + /** + * Structured content returned by the MCP server. + * Pre-populated when the widget loads (if tool has completed). + */ + toolOutput?: unknown; + + /** + * The `_meta` payload from tool response (widget-only, hidden from model). + */ + toolResponseMetadata?: Record; + + /** + * Persisted UI state snapshot between renders. + * Set via setWidgetState(), rehydrated on subsequent renders. + */ + widgetState?: unknown; + + /** + * Current theme setting. + */ + theme?: OpenAITheme; + + /** + * Current display mode of the widget. + */ + displayMode?: OpenAIDisplayMode; + + /** + * Maximum height available for the widget. + */ + maxHeight?: number; + + /** + * Safe area insets for the widget. + */ + safeArea?: OpenAISafeArea; + + /** + * Current view mode. + */ + view?: string; + + /** + * User agent string from the host. + */ + userAgent?: string; + + /** + * Locale setting (BCP 47 language tag). + */ + locale?: string; + + // ───────────────────────────────────────────────────────────────────────── + // State Management Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Persist UI state synchronously after interactions. + * State is scoped to this widget instance and rehydrated on re-renders. + * + * @param state - State object to persist + */ + setWidgetState?(state: unknown): void; + + // ───────────────────────────────────────────────────────────────────────── + // Tool & Chat Integration Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Invoke another MCP tool from the widget. + * + * @param name - Name of the tool to call + * @param args - Arguments to pass to the tool + * @returns Promise resolving to the tool result + */ + callTool?( + name: string, + args?: Record, + ): Promise; + + /** + * Inject a user message into the conversation. + * + * @param options - Message options + * @param options.prompt - The message text to send + */ + sendFollowUpMessage?(options: { prompt: string }): Promise; + + // ───────────────────────────────────────────────────────────────────────── + // File Operations + // ───────────────────────────────────────────────────────────────────────── + + /** + * Upload a user-selected file. + * + * @param file - File to upload + * @returns Promise resolving to the file ID + */ + uploadFile?(file: File): Promise<{ fileId: string }>; + + /** + * Retrieve a temporary download URL for a file. + * + * @param options - File options + * @param options.fileId - ID of the file to download + * @returns Promise resolving to the download URL + */ + getFileDownloadUrl?(options: { fileId: string }): Promise<{ url: string }>; + + // ───────────────────────────────────────────────────────────────────────── + // Layout & Display Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Request a display mode change (inline, pip, fullscreen). + * + * @param options - Display mode options + * @param options.mode - Requested display mode + */ + requestDisplayMode?(options: { mode: OpenAIDisplayMode }): Promise; + + /** + * Spawn a ChatGPT-owned modal. + */ + requestModal?(options: unknown): Promise; + + /** + * Report dynamic widget height to the host. + * + * @param height - Height in pixels + */ + notifyIntrinsicHeight?(height: number): void; + + /** + * Close the widget from the UI. + */ + requestClose?(): void; + + // ───────────────────────────────────────────────────────────────────────── + // Navigation Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Open a vetted external link in a new tab. + * + * @param options - Link options + * @param options.href - URL to open + */ + openExternal?(options: { href: string }): Promise; +} + +/** + * Window type augmentation for OpenAI environment. + */ +export interface WindowWithOpenAI { + openai: OpenAIGlobal; +} + +/** + * Detect if the current environment has window.openai available. + * + * @returns true if running in OpenAI/ChatGPT environment + */ +export function isOpenAIEnvironment(): boolean { + return ( + typeof window !== "undefined" && + typeof (window as unknown as WindowWithOpenAI).openai === "object" && + (window as unknown as WindowWithOpenAI).openai !== null + ); +} + +/** + * Get the window.openai object if available. + * + * @returns The OpenAI global object, or undefined if not in OpenAI environment + */ +export function getOpenAIGlobal(): OpenAIGlobal | undefined { + if (isOpenAIEnvironment()) { + return (window as unknown as WindowWithOpenAI).openai; + } + return undefined; +} diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index 65b93daf..778e3307 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -1,16 +1,12 @@ import { useEffect, useState } from "react"; import { Implementation } from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@modelcontextprotocol/sdk/client"; -import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; +import { App, McpUiAppCapabilities } from "../app"; export * from "../app"; /** * Options for configuring the {@link useApp} hook. * - * Note: This interface does NOT expose {@link App} options like `autoResize`. - * The hook creates the `App` with default options (`autoResize: true`). If you - * need custom `App` options, create the `App` manually instead of using this hook. - * * @see {@link useApp} for the hook that uses these options * @see {@link useAutoResize} for manual auto-resize control with custom `App` options */ @@ -21,6 +17,18 @@ export interface UseAppOptions { * Declares what features this app supports. */ capabilities: McpUiAppCapabilities; + /** + * Enable experimental OpenAI compatibility. + * + * When enabled (default), the App will auto-detect the environment: + * - If `window.openai` exists → use OpenAI Apps SDK + * - Otherwise → use MCP Apps protocol via PostMessageTransport + * + * Set to `false` to force MCP-only mode. + * + * @default true + */ + experimentalOAICompatibility?: boolean; /** * Called after {@link App} is created but before connection. * @@ -61,13 +69,13 @@ export interface AppState { /** * React hook to create and connect an MCP App. * - * This hook manages {@link App} creation and connection. It automatically - * creates a {@link PostMessageTransport} to window.parent and handles - * initialization. + * This hook manages the complete lifecycle of an {@link App}: creation, connection, + * and cleanup. It automatically detects the platform (MCP or OpenAI) and uses the + * appropriate transport. * - * This hook is part of the optional React integration. The core SDK (`App`, - * `PostMessageTransport`) is framework-agnostic and can be used with any UI - * framework or vanilla JavaScript. + * **Cross-Platform Support**: The hook supports both MCP-compatible hosts and + * OpenAI's ChatGPT environment. By default, it auto-detects the platform. + * Set `experimentalOAICompatibility: false` to force MCP-only mode. * * **Important**: The hook intentionally does NOT re-run when options change * to avoid reconnection loops. Options are only used during the initial mount. @@ -75,27 +83,27 @@ export interface AppState { * issues during React Strict Mode's double-mount cycle. If you need to * explicitly close the `App`, call {@link App.close} manually. * + * **Note**: This is part of the optional React integration. The core SDK + * (App, PostMessageTransport, OpenAITransport) is framework-agnostic and can be + * used with any UI framework or vanilla JavaScript. + * * @param options - Configuration for the app * @returns Current connection state and app instance. If connection fails during * initialization, the `error` field will contain the error (typically connection * timeouts, initialization handshake failures, or transport errors). * - * @example Basic usage + * @example Basic usage (auto-detects platform) * ```typescript - * import { useApp, McpUiToolInputNotificationSchema } from '@modelcontextprotocol/ext-apps/react'; + * import { useApp } from '@modelcontextprotocol/ext-apps/react'; * * function MyApp() { * const { app, isConnected, error } = useApp({ * appInfo: { name: "MyApp", version: "1.0.0" }, * capabilities: {}, * onAppCreated: (app) => { - * // Register handlers before connection - * app.setNotificationHandler( - * McpUiToolInputNotificationSchema, - * (notification) => { - * console.log("Tool input:", notification.params.arguments); - * } - * ); + * app.ontoolinput = (params) => { + * console.log("Tool input:", params.arguments); + * }; * }, * }); * @@ -105,12 +113,22 @@ export interface AppState { * } * ``` * + * @example Force MCP-only mode + * ```typescript + * const { app } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * experimentalOAICompatibility: false, // Disable OpenAI auto-detection + * }); + * ``` + * * @see {@link App.connect} for the underlying connection method * @see {@link useAutoResize} for manual auto-resize control when using custom App options */ export function useApp({ appInfo, capabilities, + experimentalOAICompatibility = true, onAppCreated, }: UseAppOptions): AppState { const [app, setApp] = useState(null); @@ -122,16 +140,15 @@ export function useApp({ async function connect() { try { - const transport = new PostMessageTransport( - window.parent, - window.parent, - ); - const app = new App(appInfo, capabilities); + const app = new App(appInfo, capabilities, { + experimentalOAICompatibility, + autoResize: true, + }); // Register handlers BEFORE connecting onAppCreated?.(app); - await app.connect(transport); + await app.connect(); if (mounted) { setApp(app); diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d5e0a80a..e4425583 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -4,6 +4,8 @@ import { registerAppResource, RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE, + OPENAI_RESOURCE_SUFFIX, + OPENAI_MIME_TYPE, } from "./index"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -53,6 +55,34 @@ describe("registerAppTool", () => { expect(capturedHandler).toBe(handler); }); + it("should add openai/outputTemplate metadata for cross-platform compatibility", () => { + let capturedConfig: Record | undefined; + + const mockServer = { + registerTool: mock( + (_name: string, config: Record, _handler: unknown) => { + capturedConfig = config; + }, + ), + }; + + registerAppTool( + mockServer as unknown as Pick, + "my-tool", + { + _meta: { + [RESOURCE_URI_META_KEY]: "ui://test/widget.html", + }, + }, + async () => ({ content: [{ type: "text" as const, text: "ok" }] }), + ); + + const meta = capturedConfig?._meta as Record; + expect(meta["openai/outputTemplate"]).toBe( + "ui://test/widget.html" + OPENAI_RESOURCE_SUFFIX, + ); + }); + describe("backward compatibility", () => { it("should set legacy key when _meta.ui.resourceUri is provided", () => { let capturedConfig: Record | undefined; @@ -196,18 +226,18 @@ describe("registerAppTool", () => { }); describe("registerAppResource", () => { - it("should register a resource with default MIME type", () => { - let capturedName: string | undefined; - let capturedUri: string | undefined; - let capturedConfig: Record | undefined; + it("should register both MCP and OpenAI resources", () => { + const registrations: Array<{ + name: string; + uri: string; + config: Record; + }> = []; const mockServer = { registerTool: mock(() => {}), registerResource: mock( (name: string, uri: string, config: Record) => { - capturedName = name; - capturedUri = uri; - capturedConfig = config; + registrations.push({ name, uri, config }); }, ), }; @@ -233,21 +263,32 @@ describe("registerAppResource", () => { callback, ); - expect(mockServer.registerResource).toHaveBeenCalledTimes(1); - expect(capturedName).toBe("My Resource"); - expect(capturedUri).toBe("ui://test/widget.html"); - expect(capturedConfig?.mimeType).toBe(RESOURCE_MIME_TYPE); - expect(capturedConfig?.description).toBe("A test resource"); + // Should register TWO resources (MCP + OpenAI) + expect(mockServer.registerResource).toHaveBeenCalledTimes(2); + + // First: MCP resource + expect(registrations[0].name).toBe("My Resource"); + expect(registrations[0].uri).toBe("ui://test/widget.html"); + expect(registrations[0].config.mimeType).toBe(RESOURCE_MIME_TYPE); + expect(registrations[0].config.description).toBe("A test resource"); + + // Second: OpenAI resource + expect(registrations[1].name).toBe("My Resource (OpenAI)"); + expect(registrations[1].uri).toBe( + "ui://test/widget.html" + OPENAI_RESOURCE_SUFFIX, + ); + expect(registrations[1].config.mimeType).toBe(OPENAI_MIME_TYPE); + expect(registrations[1].config.description).toBe("A test resource"); }); - it("should allow custom MIME type to override default", () => { - let capturedConfig: Record | undefined; + it("should allow custom MIME type to override default for MCP resource", () => { + const registrations: Array<{ config: Record }> = []; const mockServer = { registerTool: mock(() => {}), registerResource: mock( (_name: string, _uri: string, config: Record) => { - capturedConfig = config; + registrations.push({ config }); }, ), }; @@ -271,12 +312,16 @@ describe("registerAppResource", () => { }), ); - // Custom mimeType should override the default - expect(capturedConfig?.mimeType).toBe("text/html"); + // MCP resource should use custom mimeType + expect(registrations[0].config.mimeType).toBe("text/html"); + // OpenAI resource should always use skybridge MIME type + expect(registrations[1].config.mimeType).toBe(OPENAI_MIME_TYPE); }); - it("should call the callback when handler is invoked", async () => { - let capturedHandler: (() => Promise) | undefined; + it("should transform OpenAI resource callback to use skybridge MIME type", async () => { + let mcpHandler: (() => Promise) | undefined; + let openaiHandler: (() => Promise) | undefined; + let callCount = 0; const mockServer = { registerTool: mock(() => {}), @@ -287,12 +332,17 @@ describe("registerAppResource", () => { _config: unknown, handler: () => Promise, ) => { - capturedHandler = handler; + if (callCount === 0) { + mcpHandler = handler; + } else { + openaiHandler = handler; + } + callCount++; }, ), }; - const expectedResult = { + const callback = mock(async () => ({ contents: [ { uri: "ui://test/widget.html", @@ -300,8 +350,7 @@ describe("registerAppResource", () => { text: "content", }, ], - }; - const callback = mock(async () => expectedResult); + })); registerAppResource( mockServer as unknown as Pick, @@ -311,10 +360,70 @@ describe("registerAppResource", () => { callback, ); - expect(capturedHandler).toBeDefined(); - const result = await capturedHandler!(); + // MCP handler should return original content + const mcpResult = (await mcpHandler!()) as { + contents: Array<{ uri: string; mimeType: string }>; + }; + expect(mcpResult.contents[0].mimeType).toBe(RESOURCE_MIME_TYPE); + + // OpenAI handler should return with skybridge MIME type + const openaiResult = (await openaiHandler!()) as { + contents: Array<{ uri: string; mimeType: string }>; + }; + expect(openaiResult.contents[0].uri).toBe( + "ui://test/widget.html" + OPENAI_RESOURCE_SUFFIX, + ); + expect(openaiResult.contents[0].mimeType).toBe(OPENAI_MIME_TYPE); + }); + + it("should preserve custom MIME types in OpenAI resource callback", async () => { + let openaiHandler: (() => Promise) | undefined; + let callCount = 0; + + const mockServer = { + registerTool: mock(() => {}), + registerResource: mock( + ( + _name: string, + _uri: string, + _config: unknown, + handler: () => Promise, + ) => { + if (callCount === 1) { + openaiHandler = handler; + } + callCount++; + }, + ), + }; + + // Callback returns custom MIME type (not the default MCP App type) + const callback = mock(async () => ({ + contents: [ + { + uri: "ui://test/widget.html", + mimeType: "application/json", + text: "{}", + }, + ], + })); - expect(callback).toHaveBeenCalledTimes(1); - expect(result).toEqual(expectedResult); + registerAppResource( + mockServer as unknown as Pick, + "My Resource", + "ui://test/widget.html", + { _meta: { ui: {} } }, + callback, + ); + + // OpenAI handler should preserve the custom MIME type + const openaiResult = (await openaiHandler!()) as { + contents: Array<{ uri: string; mimeType: string }>; + }; + expect(openaiResult.contents[0].uri).toBe( + "ui://test/widget.html" + OPENAI_RESOURCE_SUFFIX, + ); + // Custom MIME type should be preserved, not converted to skybridge + expect(openaiResult.contents[0].mimeType).toBe("application/json"); }); }); diff --git a/src/server/index.ts b/src/server/index.ts index f242e2a2..256b8aee 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,6 +5,26 @@ * your tool should render an {@link app!App} in the client. They handle UI metadata normalization * and provide sensible defaults for the MCP Apps MIME type ({@link RESOURCE_MIME_TYPE}). * + * These utilities register tools and resources that work with both + * MCP-compatible hosts and OpenAI's ChatGPT Apps SDK. + * + * ## Cross-Platform Support + * + * | Feature | MCP Apps | OpenAI Apps SDK | + * |---------|----------|-----------------| + * | Tool metadata | `_meta.ui.resourceUri` | `_meta["openai/outputTemplate"]` | + * | Resource MIME | `text/html;profile=mcp-app` | `text/html+skybridge` | + * + * These utilities register tools and resources that work with both + * MCP-compatible hosts and OpenAI's ChatGPT Apps SDK. + * + * ## Cross-Platform Support + * + * | Feature | MCP Apps | OpenAI Apps SDK | + * |---------|----------|-----------------| + * | Tool metadata | `_meta.ui.resourceUri` | `_meta["openai/outputTemplate"]` | + * | Resource MIME | `text/html;profile=mcp-app` | `text/html+skybridge` | + * * @module server-helpers * * @example @@ -46,6 +66,17 @@ import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE }; export type { ResourceMetadata, ToolCallback, ReadResourceCallback }; +/** + * OpenAI skybridge URI suffix. + * Appended to resource URIs for OpenAI-specific resource registration. + */ +export const OPENAI_RESOURCE_SUFFIX = "+skybridge"; + +/** + * OpenAI skybridge MIME type. + */ +export const OPENAI_MIME_TYPE = "text/html+skybridge"; + /** * Base tool configuration matching the standard MCP server tool options. * Extended by {@link McpUiAppToolConfig} to add UI metadata requirements. @@ -79,7 +110,7 @@ export interface McpUiAppToolConfig extends ToolConfig { | { /** * URI of the UI resource to display for this tool. - * This is converted to `_meta["ui/resourceUri"]`. + * This is converted to `_meta.ui.resourceUri`. * * @example "ui://weather/widget.html" * @@ -208,15 +239,31 @@ export function registerAppTool< normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } }; } + // Get the resource URI after normalization + const resourceUri = (normalizedMeta.ui as McpUiToolMeta | undefined) + ?.resourceUri; + + // Add OpenAI outputTemplate metadata for cross-platform compatibility + if (resourceUri) { + normalizedMeta = { + ...normalizedMeta, + "openai/outputTemplate": resourceUri + OPENAI_RESOURCE_SUFFIX, + }; + } + return server.registerTool(name, { ...config, _meta: normalizedMeta }, cb); } /** - * Register an app resource with the MCP server. + * Register an app resource with dual MCP/OpenAI support. * * This is a convenience wrapper around `server.registerResource` that: * - Defaults the MIME type to {@link RESOURCE_MIME_TYPE} (`"text/html;profile=mcp-app"`) - * - Provides a cleaner API matching the SDK's callback signature + * - Registers both MCP and OpenAI variants for cross-platform compatibility + * + * Registers two resources: + * 1. MCP resource at the base URI with `text/html;profile=mcp-app` MIME type + * 2. OpenAI resource at URI+skybridge with `text/html+skybridge` MIME type * * @param server - The MCP server instance * @param name - Human-readable resource name @@ -263,6 +310,9 @@ export function registerAppResource( config: McpUiAppResourceConfig, readCallback: ReadResourceCallback, ): void { + const openaiUri = uri + OPENAI_RESOURCE_SUFFIX; + + // Register MCP resource (text/html;profile=mcp-app) server.registerResource( name, uri, @@ -273,4 +323,30 @@ export function registerAppResource( }, readCallback, ); + + // Register OpenAI resource (text/html+skybridge) + // Re-uses the same callback but returns with OpenAI MIME type + server.registerResource( + name + " (OpenAI)", + openaiUri, + { + ...config, + // Force OpenAI MIME type + mimeType: OPENAI_MIME_TYPE, + }, + async (resourceUri, extra) => { + const result = await readCallback(resourceUri, extra); + // Transform contents to use OpenAI MIME type + return { + contents: result.contents.map((content) => ({ + ...content, + uri: content.uri + OPENAI_RESOURCE_SUFFIX, + mimeType: + content.mimeType === RESOURCE_MIME_TYPE + ? OPENAI_MIME_TYPE + : content.mimeType, + })), + }; + }, + ); } diff --git a/src/spec.types.ts b/src/spec.types.ts index cb6af1f7..986c39b9 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -283,6 +283,56 @@ export interface McpUiToolCancelledNotification { }; } +/** + * @description Notification containing persisted widget state (Host -> Guest UI). + * + * This notification delivers previously persisted UI state on widget load. + * In OpenAI mode, this comes from window.openai.widgetState. Apps use this + * to hydrate their UI state from previous sessions. + * + * The state can be either a simple object or a StructuredWidgetState with + * separate modelContent/privateContent/imageIds fields. + */ +export interface McpUiWidgetStateNotification { + method: "ui/notifications/widget-state"; + params: { + /** @description The persisted widget state from previous interaction. */ + state: Record; + }; +} + +/** + * @description Notification to update model context and persist widget state (Guest UI -> Host). + * + * This notification allows apps to update what the model sees for follow-up turns + * and persist UI state. In OpenAI mode, this calls window.openai.setWidgetState(). + * + * Use the structured format with modelContent/privateContent/imageIds for fine-grained + * control over what the model sees vs. what stays private to the UI. + */ +export interface McpUiUpdateModelContextNotification { + method: "ui/notifications/update-model-context"; + params: { + /** + * @description Text or JSON the model should see for follow-up reasoning. + * Keep focused and under 4k tokens. + */ + modelContent?: string | Record | null; + + /** + * @description UI-only state the model should NOT see. + * Use for ephemeral UI details like current view, filters, selections. + */ + privateContent?: Record | null; + + /** + * @description File IDs for images the model should reason about. + * Use file IDs from uploadFile() or received as file params. + */ + imageIds?: string[]; + }; +} + /** * @description CSS blocks that can be injected by apps. */ @@ -630,6 +680,65 @@ export interface McpUiToolMeta { visibility?: McpUiToolVisibility[]; } +/** + * @description Request to upload a file for use in model context. + * + * This allows apps to upload images and other files that can be referenced + * in model context via imageIds in updateModelContext. + * + * @see {@link app.App.uploadFile} for the method that sends this request + */ +export interface McpUiUploadFileRequest { + method: "ui/upload-file"; + params: { + /** @description File name with extension */ + name: string; + /** @description MIME type of the file */ + mimeType: string; + /** @description Base64-encoded file data */ + data: string; + }; +} + +/** + * @description Result from uploading a file. + * @see {@link McpUiUploadFileRequest} + */ +export interface McpUiUploadFileResult { + /** @description The file ID to use in imageIds for model context */ + fileId: string; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + */ + [key: string]: unknown; +} + +/** + * @description Request to get a download URL for a previously uploaded file. + * + * @see {@link app.App.getFileDownloadUrl} for the method that sends this request + */ +export interface McpUiGetFileUrlRequest { + method: "ui/get-file-url"; + params: { + /** @description The file ID from a previous upload */ + fileId: string; + }; +} + +/** + * @description Result from getting a file download URL. + * @see {@link McpUiGetFileUrlRequest} + */ +export interface McpUiGetFileUrlResult { + /** @description Temporary download URL for the file */ + url: string; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + */ + [key: string]: unknown; +} + /** * Method string constants for MCP Apps protocol messages. * diff --git a/src/types.ts b/src/types.ts index 77563dc8..0f1916cb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,12 @@ export { type McpUiRequestDisplayModeResult, type McpUiToolVisibility, type McpUiToolMeta, + type McpUiWidgetStateNotification, + type McpUiUpdateModelContextNotification, + type McpUiUploadFileRequest, + type McpUiUploadFileResult, + type McpUiGetFileUrlRequest, + type McpUiGetFileUrlResult, } from "./spec.types.js"; // Import types needed for protocol type unions (not re-exported, just used internally) @@ -76,6 +82,8 @@ import type { McpUiToolInputPartialNotification, McpUiToolResultNotification, McpUiToolCancelledNotification, + McpUiWidgetStateNotification, + McpUiUpdateModelContextNotification, McpUiSandboxResourceReadyNotification, McpUiInitializedNotification, McpUiSizeChangedNotification, @@ -85,6 +93,10 @@ import type { McpUiMessageResult, McpUiResourceTeardownResult, McpUiRequestDisplayModeResult, + McpUiUploadFileRequest, + McpUiUploadFileResult, + McpUiGetFileUrlRequest, + McpUiGetFileUrlResult, } from "./spec.types.js"; // Re-export all schemas from generated/schema.ts (already PascalCase) @@ -122,6 +134,12 @@ export { McpUiRequestDisplayModeResultSchema, McpUiToolVisibilitySchema, McpUiToolMetaSchema, + McpUiWidgetStateNotificationSchema, + McpUiUpdateModelContextNotificationSchema, + McpUiUploadFileRequestSchema, + McpUiUploadFileResultSchema, + McpUiGetFileUrlRequestSchema, + McpUiGetFileUrlResultSchema, } from "./generated/schema.js"; // Re-export SDK types used in protocol type unions @@ -162,6 +180,8 @@ export type AppRequest = | McpUiUpdateModelContextRequest | McpUiResourceTeardownRequest | McpUiRequestDisplayModeRequest + | McpUiUploadFileRequest + | McpUiGetFileUrlRequest | CallToolRequest | ListToolsRequest | ListResourcesRequest @@ -190,6 +210,7 @@ export type AppNotification = | McpUiToolInputPartialNotification | McpUiToolResultNotification | McpUiToolCancelledNotification + | McpUiWidgetStateNotification | McpUiSandboxResourceReadyNotification | ToolListChangedNotification | ResourceListChangedNotification @@ -198,6 +219,7 @@ export type AppNotification = | McpUiInitializedNotification | McpUiSizeChangedNotification | McpUiSandboxProxyReadyNotification + | McpUiUpdateModelContextNotification | LoggingMessageNotification; /** @@ -209,6 +231,8 @@ export type AppResult = | McpUiMessageResult | McpUiResourceTeardownResult | McpUiRequestDisplayModeResult + | McpUiUploadFileResult + | McpUiGetFileUrlResult | CallToolResult | ListToolsResult | ListResourcesResult diff --git a/tests/e2e/servers.spec.ts-snapshots/threejs.png b/tests/e2e/servers.spec.ts-snapshots/threejs.png index 6752d9b1..fbbb8e71 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/threejs.png and b/tests/e2e/servers.spec.ts-snapshots/threejs.png differ