From 92f42cf53333909075af74edbab7fd6feb88d4ab Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 21 Nov 2025 11:56:27 +0000 Subject: [PATCH] Initial commit --- .gitignore | 3 + app.ts | 209 ++++++++++++++++++++ build.ts | 69 +++++++ examples/example-server.ts | 297 ++++++++++++++++++++++++++++ examples/example-ui-raw.ts | 209 ++++++++++++++++++++ examples/example-ui-react.html | 12 ++ examples/example-ui-react.tsx | 194 ++++++++++++++++++ examples/example-ui.html | 2 + examples/example-ui.ts | 143 ++++++++++++++ index.ts | 3 + message-transport.ts | 58 ++++++ package.json | 42 ++++ react/useApp.tsx | 70 +++++++ react/useAutoResize.ts | 20 ++ src/app.ts | 209 ++++++++++++++++++++ src/index.ts | 3 + src/message-transport.ts | 58 ++++++ src/peer.ts_ | 351 +++++++++++++++++++++++++++++++++ src/react/useApp.tsx | 70 +++++++ src/react/useAutoResize.ts | 20 ++ src/types.ts | 236 ++++++++++++++++++++++ tsconfig.json | 20 ++ types.ts | 236 ++++++++++++++++++++++ 23 files changed, 2534 insertions(+) create mode 100644 .gitignore create mode 100644 app.ts create mode 100644 build.ts create mode 100644 examples/example-server.ts create mode 100644 examples/example-ui-raw.ts create mode 100644 examples/example-ui-react.html create mode 100644 examples/example-ui-react.tsx create mode 100644 examples/example-ui.html create mode 100644 examples/example-ui.ts create mode 100644 index.ts create mode 100644 message-transport.ts create mode 100644 package.json create mode 100644 react/useApp.tsx create mode 100644 react/useAutoResize.ts create mode 100644 src/app.ts create mode 100644 src/index.ts create mode 100644 src/message-transport.ts create mode 100644 src/peer.ts_ create mode 100644 src/react/useApp.tsx create mode 100644 src/react/useAutoResize.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json create mode 100644 types.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c1f900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +dist/ +node_modules/ diff --git a/app.ts b/app.ts new file mode 100644 index 0000000..d02458d --- /dev/null +++ b/app.ts @@ -0,0 +1,209 @@ +import { + type RequestOptions, + Protocol, + ProtocolOptions, +} from "@modelcontextprotocol/sdk/shared/protocol.js"; + +import { + CallToolRequest, + CallToolResult, + CallToolResultSchema, + Implementation, + LoggingMessageNotification, + Notification, + PingRequestSchema, + Request, + Result, +} from "@modelcontextprotocol/sdk/types.js"; +import { + LATEST_PROTOCOL_VERSION, + McpUiAppCapabilities, + McpUiHostCapabilities, + McpUiHostInfo, + McpUiInitializedNotification, + McpUiInitializeRequest, + McpUiInitializeResultSchema, + McpUiMessageRequest, + McpUiMessageResultSchema, + McpUiOpenLinkRequest, + McpUiOpenLinkResultSchema, + McpUiSizeChangeNotification, +} from "./types"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +export { PostMessageTransport } from "./message-transport.js"; +export * from "./types"; + +type AppOptions = ProtocolOptions & { + autoResize?: boolean; +}; + +export class App extends Protocol { + private _hostCapabilities?: McpUiHostCapabilities; + private _hostInfo?: McpUiHostInfo; + + constructor( + private _appInfo: Implementation, + private _capabilities: McpUiAppCapabilities = {}, + private options: AppOptions = { autoResize: true }, + ) { + super(options); + + this.setRequestHandler(PingRequestSchema, (request) => { + console.log("Received ping:", request.params); + return {}; + }); + } + + assertCapabilityForMethod(method: Request["method"]): void { + // TODO + } + assertRequestHandlerCapability(method: Request["method"]): void { + switch (method) { + case "tools/call": + case "tools/list": + if (!this._capabilities.tools) { + throw new Error( + `Client does not support tool capability (required for ${method})`, + ); + } + return; + case "ping": + return; + default: + throw new Error(`No handler for method ${method} registered`); + } + } + assertNotificationCapability(method: Notification["method"]): void { + // TODO + } + + async callServerTool( + params: CallToolRequest["params"], + options?: RequestOptions, + ): Promise { + return await this.request( + { method: "tools/call", params }, + CallToolResultSchema, + options, + ); + } + + sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) { + return this.request( + { + method: "ui/message", + params, + }, + McpUiMessageResultSchema, + options, + ); + } + + sendLog(params: LoggingMessageNotification["params"]) { + return this.notification({ + method: "notifications/message", + params, + }); + } + + sendOpenLink( + params: McpUiOpenLinkRequest["params"], + options?: RequestOptions, + ) { + return this.request( + { + method: "ui/open-link", + params, + }, + McpUiOpenLinkResultSchema, + options, + ); + } + + sendSizeChange(params: McpUiSizeChangeNotification["params"]) { + return this.notification({ + method: "ui/notifications/size-change", + params, + }); + } + + setupSizeChangeNotifications() { + const sendBodySizeChange = () => { + let rafId: number | null = null; + + // Debounce using requestAnimationFrame to avoid duplicate messages + // when both documentElement and body fire resize events + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + const { body, documentElement: html } = document; + + const bodyStyle = getComputedStyle(body); + const htmlStyle = getComputedStyle(html); + + const width = body.scrollWidth; + const height = + body.scrollHeight + + (parseFloat(bodyStyle.borderTop) || 0) + + (parseFloat(bodyStyle.borderBottom) || 0) + + (parseFloat(htmlStyle.borderTop) || 0) + + (parseFloat(htmlStyle.borderBottom) || 0); + + this.sendSizeChange({ width, height }); + rafId = null; + }); + }; + + sendBodySizeChange(); + + const resizeObserver = new ResizeObserver(sendBodySizeChange); + // Observe both html and body to catch all size changes + resizeObserver.observe(document.documentElement); + resizeObserver.observe(document.body); + + return () => resizeObserver.disconnect(); + } + + override async connect( + transport: Transport, + options?: RequestOptions, + ): Promise { + await super.connect(transport); + + try { + const result = await this.request( + { + method: "ui/initialize", + params: { + appCapabilities: this._capabilities, + appInfo: this._appInfo, + protocolVersion: LATEST_PROTOCOL_VERSION, + }, + }, + McpUiInitializeResultSchema, + options, + ); + + if (result === undefined) { + throw new Error(`Server sent invalid initialize result: ${result}`); + } + + this._hostCapabilities = result.hostCapabilities; + this._hostInfo = result.hostInfo; + + await this.notification({ + method: "ui/notifications/initialized", + }); + + if (this.options?.autoResize) { + this.setupSizeChangeNotifications(); + } + } catch (error) { + // Disconnect if initialization fails. + void this.close(); + throw error; + } + } +} diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..d157b91 --- /dev/null +++ b/build.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env tsx +import { execSync } from "child_process"; +import * as esbuild from "esbuild"; +import { readFile, writeFile } from "fs/promises"; + +// Run TypeScript compiler for type declarations +execSync("tsc", { stdio: "inherit" }); + +const isDevelopment = process.env.NODE_ENV === "development"; + +// Build all JavaScript/TypeScript files +async function buildJs( + entrypoint: string, + opts: { outdir?: string; target?: "browser" | "node" } = {}, +) { + return esbuild.build({ + entryPoints: [entrypoint], + outdir: opts.outdir ?? "dist", + platform: opts.target === "node" ? "node" : "browser", + format: "esm", + bundle: true, + minify: !isDevelopment, + sourcemap: isDevelopment ? "inline" : false, + }); +} + +await Promise.all([ + buildJs("src/index.ts", { outdir: "dist/src" }), + buildJs("src/react/useApp.tsx", { outdir: "dist/src/react" }), + buildJs("examples/example-ui-react.tsx", { outdir: "dist/examples" }), + buildJs("examples/example-ui.ts", { outdir: "dist/examples" }), + buildJs("examples/example-server.ts", { + target: "node", + outdir: "dist/examples", + }), +]); + +async function inlineHtml(htmlFilePath: string, outputFilePath: string) { + let html = await readFile(htmlFilePath, "utf-8"); + // Find all src sources, then fetch them, then replace them + const files = html.matchAll( + / to prevent premature script tag closing + // This is the standard way to inline scripts safely + scriptContent = scriptContent.replace(/<\/script>/gi, "<\\/script>"); + return ``; + }, + ); + await writeFile(outputFilePath, html); +} + +await inlineHtml("examples/example-ui.html", "dist/examples/example-ui.html"); +await inlineHtml( + "examples/example-ui-react.html", + "dist/examples/example-ui-react.html", +); diff --git a/examples/example-server.ts b/examples/example-server.ts new file mode 100644 index 0000000..c9f9220 --- /dev/null +++ b/examples/example-server.ts @@ -0,0 +1,297 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { type Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import express, { Request, Response } from "express"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + CallToolResult, + GetPromptResult, + isInitializeRequest, + PrimitiveSchemaDefinition, + ReadResourceResult, + Resource, + ResourceLink, +} from "@modelcontextprotocol/sdk/types.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; + +import cors from "cors"; +import { text } from "node:stream/consumers"; +import path from "node:path"; +import fs from "node:fs/promises"; + +// const isProduction = process.env.NODE_ENV !== 'development'; +const exampleUiPath = __filename.endsWith(".ts") + ? path.join(__dirname, "dist", "example-ui.html") + : path.join(__dirname, "example-server.html"); +const exampleUiHtml = await fs.readFile(exampleUiPath, "utf-8"); + +// Create an MCP server with implementation details +const getServer = async () => { + const server = new McpServer( + { + name: "example-mcp-ui-server", + version: "1.0.0", + icons: [ + { src: "./mcp.svg", sizes: ["512x512"], mimeType: "image/svg+xml" }, + ], + websiteUrl: "https://github.com/modelcontextprotocol/typescript-sdk", + }, + { capabilities: { logging: {} } }, + ); + + const exampleUiTemplateResource: Resource = { + name: "example-ui-template", + uri: "ui://example-ui-template", + title: "Greeting Template", + description: "A simple greeting UI", + mimeType: "text/html", + }; + + server.registerResource( + exampleUiTemplateResource.name, + exampleUiTemplateResource.uri, + exampleUiTemplateResource, + async (): Promise => { + return { + contents: [ + { + uri: exampleUiTemplateResource.uri, + mimeType: exampleUiTemplateResource.mimeType, + text: exampleUiHtml, + }, + ], + }; + }, + ); + + server.registerTool( + "create-example-ui", + { + title: "Example UI", + description: "A tool that returns a simple UI", + inputSchema: { + message: z.string().describe("Name to greet"), + }, + _meta: { + "ui/resourceUri": exampleUiTemplateResource.uri, + }, + }, + async ({ message }): Promise => { + const structuredContent = { + message, + }; + return { + // content: [], + content: [ + { + type: "text", + text: JSON.stringify(structuredContent), + }, + ], + structuredContent, + }; + }, + ); + + server.registerTool( + "get-weather", + { + title: "Get Weather", + description: + "A tool that returns the current weather for a given location", + inputSchema: { + location: z.string().describe("Location to get the weather for"), + }, + }, + async ({ location }): Promise => { + // For simplicity, return a fixed weather report + const temperature = 25; // Fixed temperature + const condition = "sunny"; + const weatherReport = `The current weather in ${location} is ${condition} with a temperature of ${temperature}°C.`; + const structuredContent = { + temperature, + condition, + }; + return { + content: [ + { + type: "text", + text: weatherReport, + }, + ], + structuredContent, + }; + }, + ); + + return server; +}; + +const MCP_PORT = process.env.MCP_PORT + ? parseInt(process.env.MCP_PORT, 10) + : 3001; +const MCP_HOST = process.env.MCP_HOST || "localhost"; + +const app = express(); +app.use(express.json()); + +// Allow CORS all domains, expose the Mcp-Session-Id header +app.use( + cors({ + origin: "*", // Allow all origins + exposedHeaders: ["Mcp-Session-Id"], + }), +); + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (sessionId) { + console.log(`Received MCP request for session: ${sessionId}`); + } else { + console.log("Request body:", req.body); + } + + try { + let transport: StreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + }, + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log( + `Transport closed for session ${sid}, removing from transports map`, + ); + delete transports[sid]; + } + }; + + // Connect the transport to the MCP server BEFORE handling the request + // so responses can flow back through the same transport + const server = await getServer(); + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + return; // Already handled + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: null, + }); + return; + } + + // Handle the request with existing transport - no need to reconnect + // The existing transport is already connected to the server + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal server error", + }, + id: null, + }); + } + } +}; + +app.post("/mcp", mcpPostHandler); + +// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) +const mcpGetHandler = async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send("Invalid or missing session ID"); + return; + } + + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers["last-event-id"] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing new SSE stream for session ${sessionId}`); + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}; + +app.get("/mcp", mcpGetHandler); + +// Handle DELETE requests for session termination (according to MCP spec) +const mcpDeleteHandler = async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send("Invalid or missing session ID"); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error("Error handling session termination:", error); + if (!res.headersSent) { + res.status(500).send("Error processing session termination"); + } + } +}; + +app.delete("/mcp", mcpDeleteHandler); + +app.listen(MCP_PORT, (error) => { + if (error) { + console.error("Failed to start server:", error); + process.exit(1); + } + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); +}); + +// Handle server shutdown +process.on("SIGINT", async () => { + console.log("Shutting down server..."); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log("Server shutdown complete"); + process.exit(0); +}); diff --git a/examples/example-ui-raw.ts b/examples/example-ui-raw.ts new file mode 100644 index 0000000..15bf219 --- /dev/null +++ b/examples/example-ui-raw.ts @@ -0,0 +1,209 @@ +import { App, PostMessageTransport } from "@ant/mcp-apps"; +import { + McpUiToolInputNotificationSchema, + McpUiSizeChangeNotificationSchema, + McpUiToolResultNotificationSchema, +} from "../src/types.js"; +import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; + +let nextId = 1; +function sendRequest(method: string, params: any) { + const id = nextId++; + window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); + return new Promise((resolve, reject) => { + window.addEventListener("message", function listener(event) { + const data: JSONRPCMessage = event.data; + if (event.data?.id === id) { + window.removeEventListener("message", listener); + if (event.data?.result) { + resolve(true); + } else if (event.data?.error) { + reject(new Error(event.data.error)); + } + } else { + reject(new Error(`Unsupported message: ${JSON.stringify(data)}`)); + } + }); + }); +} +function sendNotification(method: string, params: any) { + window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*"); +} +function onNotification(method: string, handler: (params: any) => void) { + window.addEventListener("message", function listener(event) { + if (event.data?.method === method) { + handler(event.data.params); + } + }); +} + +window.addEventListener("load", async () => { + const root = document.getElementById("root")!; + const appendText = (textContent: string, opts = {}) => { + root.appendChild( + Object.assign(document.createElement("div"), { + textContent, + ...opts, + }), + ); + }; + const appendError = (error: unknown) => + appendText( + `Error: ${error instanceof Error ? error.message : String(error)}`, + { style: "color: red;" }, + ); + + const initializeResult = await sendRequest("ui/initialize", { + appCapabilities: {}, + hostInfo: { + name: "MCP UI Client", + version: "1.0.0", + }, + protocolVersion: "2025-06-18", + }); + + sendNotification("ui/initialized", {}); + + // onNotification(McpUiToolInputNotificationSchema.shape.method._def.value, (params: + client.setNotificationHandler( + McpUiToolResultNotificationSchema, + async ({ params: { content, structuredContent, isError } }) => { + appendText( + `Tool call result received: isError=${isError}, content=${content}, structuredContent=${JSON.stringify(structuredContent)}`, + ); + }, + ); + client.setNotificationHandler( + McpUiSizeChangeNotificationSchema, + async ({ params: { width, height } }) => { + appendText( + `Size change notification received: width=${width}, height=${height}`, + ); + }, + ); + client.setNotificationHandler( + McpUiToolInputNotificationSchema, + async ({ params }) => { + appendText( + `Tool call input received: ${JSON.stringify(params.arguments)}`, + ); + }, + ); + + document.body.addEventListener("resize", () => { + client.sendSizeChange({ + width: document.body.scrollWidth, + height: document.body.scrollHeight, + }); + }); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Buy Groceries (Intent)", + onclick: async () => { + const signal = AbortSignal.timeout(5000); + try { + const { isError } = await client.sendIntent( + { + intent: "create-task", + params: { + title: "Buy groceries", + description: "Buy groceries for the week", + }, + }, + { signal }, + ); + appendText(`Intent result: ${isError ? "error" : "success"}`); + } catch (e) { + if (signal.aborted) { + appendError("Intent request timed out"); + return; + } + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Get Weather (Tool)", + onclick: async () => { + const signal = AbortSignal.timeout(5000); + try { + const result = await client.callTool({ + name: "get-weather", + arguments: { location: "Tokyo" }, + }); + appendText(`Weather tool result: ${JSON.stringify(result)}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Notify Cart Updated", + onclick: async () => { + try { + await client.sendNotify({ + level: "info", + data: "cart-updated", + }); + } catch (e) { + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Prompt Weather in Tokyo", + onclick: async () => { + const signal = AbortSignal.timeout(5000); + try { + const { isError } = await client.sendPrompt( + { + role: "user", // Forced. + content: [ + { + type: "text", + text: "What is the weather in Tokyo?", + }, + ], + }, + { signal }, + ); + appendText(`Prompt result: ${isError ? "error" : "success"}`); + } catch (e) { + if (signal.aborted) { + appendError("Prompt request timed out"); + return; + } + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Open Link to Google", + onclick: async () => { + try { + const { isError } = await client.sendOpenLink({ + url: "https://www.google.com", + }); + appendText(`Open link result: ${isError ? "error" : "success"}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + await client.connect(transport); +}); diff --git a/examples/example-ui-react.html b/examples/example-ui-react.html new file mode 100644 index 0000000..20447da --- /dev/null +++ b/examples/example-ui-react.html @@ -0,0 +1,12 @@ + + + + + + MCP UI Client (React) + + +
+ + + diff --git a/examples/example-ui-react.tsx b/examples/example-ui-react.tsx new file mode 100644 index 0000000..b18d3df --- /dev/null +++ b/examples/example-ui-react.tsx @@ -0,0 +1,194 @@ +import { useState, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "../src/react/useApp"; +import type { + CallToolResult, + Implementation, +} from "@modelcontextprotocol/sdk/types.js"; +import { + McpUiSizeChangeNotificationSchema, + McpUiToolResultNotificationSchema, +} from "../src/types"; + +const APP_INFO: Implementation = { + name: "MCP UI React Example Client", + version: "1.0.0", + protocolVersion: "2025-06-18", +}; +export function McpClientApp() { + const [toolResults, setToolResults] = useState([]); + const [messages, setMessages] = useState([]); + + const { app, isConnected, error } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + onAppCreated: (app) => { + app.setNotificationHandler( + McpUiToolResultNotificationSchema, + async (notification) => { + setToolResults((prev) => [...prev, notification.params]); + }, + ); + app.setNotificationHandler( + McpUiSizeChangeNotificationSchema, + async (notification) => { + document.body.style.width = `${notification.params.width}px`; + document.body.style.height = `${notification.params.height}px`; + }, + ); + }, + }); + + const handleGetWeather = useCallback(async () => { + if (!app) return; + try { + const result = await app.callServerTool({ + name: "get-weather", + arguments: { city: "Tokyo" }, + }); + setMessages((prev) => [ + ...prev, + `Weather tool result: ${JSON.stringify(result)}`, + ]); + } catch (e) { + setMessages((prev) => [...prev, `Tool call error: ${e}`]); + } + }, [app]); + + const handleNotifyCart = useCallback(async () => { + if (!app) return; + await app.sendLog({ level: "info", data: "cart-updated" }); + setMessages((prev) => [...prev, "Notification sent: cart-updated"]); + }, [app]); + + const handlePromptWeather = useCallback(async () => { + if (!app) return; + const signal = AbortSignal.timeout(5000); + try { + const { isError } = await app.sendMessage( + { + role: "user", + content: [ + { + type: "text", + text: "What is the weather in Tokyo?", + }, + ], + }, + { signal }, + ); + setMessages((prev) => [ + ...prev, + `Prompt result: ${isError ? "error" : "success"}`, + ]); + } catch (e) { + if (signal.aborted) { + setMessages((prev) => [...prev, "Prompt request timed out"]); + return; + } + setMessages((prev) => [...prev, `Prompt error: ${e}`]); + } + }, [app]); + + const handleOpenLink = useCallback(async () => { + if (!app) return; + const { isError } = await app.sendOpenLink({ + url: "https://www.google.com", + }); + setMessages((prev) => [ + ...prev, + `Open link result: ${isError ? "error" : "success"}`, + ]); + }, [app]); + + if (error) { + return ( +
Error connecting: {error.message}
+ ); + } + + if (!isConnected) { + return
Connecting...
; + } + + return ( +
+

MCP UI Client (React)

+ +
+ + + + + + + +
+ + {toolResults.length > 0 && ( +
+

Tool Results:

+ {toolResults.map((result, i) => ( +
+ isError: {String(result.isError ?? false)} +
+ content: {JSON.stringify(result.content)} +
+ {result.structuredContent && ( + <> + structuredContent:{" "} + {JSON.stringify(result.structuredContent)} + + )} +
+ ))} +
+ )} + + {messages.length > 0 && ( +
+

Messages:

+ {messages.map((msg, i) => ( +
+ {msg} +
+ ))} +
+ )} +
+ ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); diff --git a/examples/example-ui.html b/examples/example-ui.html new file mode 100644 index 0000000..83b9a3a --- /dev/null +++ b/examples/example-ui.html @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/examples/example-ui.ts b/examples/example-ui.ts new file mode 100644 index 0000000..34dd41d --- /dev/null +++ b/examples/example-ui.ts @@ -0,0 +1,143 @@ +import { App, PostMessageTransport } from "../src/app.js"; +import { + McpUiToolInputNotificationSchema, + McpUiSizeChangeNotificationSchema, + McpUiToolResultNotificationSchema, +} from "../src/types.js"; + +window.addEventListener("load", async () => { + const root = document.getElementById("root")!; + const appendText = (textContent: string, opts = {}) => { + root.appendChild( + Object.assign(document.createElement("div"), { + textContent, + ...opts, + }), + ); + }; + const appendError = (error: unknown) => + appendText( + `Error: ${error instanceof Error ? error.message : String(error)}`, + { style: "color: red;" }, + ); + + const transport = new PostMessageTransport(window.parent); + const app = new App({ + name: "MCP UI Client", + version: "1.0.0", + }); + + app.setNotificationHandler( + McpUiToolResultNotificationSchema, + async ({ params: { content, structuredContent, isError } }) => { + appendText( + `Tool call result received: isError=${isError}, content=${content}, structuredContent=${JSON.stringify(structuredContent)}`, + ); + }, + ); + app.setNotificationHandler( + McpUiSizeChangeNotificationSchema, + async ({ params: { width, height } }) => { + appendText( + `Size change notification received: width=${width}, height=${height}`, + ); + }, + ); + app.setNotificationHandler( + McpUiToolInputNotificationSchema, + async ({ params }) => { + appendText( + `Tool call input received: ${JSON.stringify(params.arguments)}`, + ); + }, + ); + + document.body.addEventListener("resize", () => { + app.sendSizeChange({ + width: document.body.scrollWidth, + height: document.body.scrollHeight, + }); + }); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Get Weather (Tool)", + onclick: async () => { + const signal = AbortSignal.timeout(5000); + try { + const result = await app.callServerTool({ + name: "get-weather", + arguments: { location: "Tokyo" }, + }); + appendText(`Weather tool result: ${JSON.stringify(result)}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Notify Cart Updated", + onclick: async () => { + try { + await app.sendLog({ + level: "info", + data: "cart-updated", + }); + } catch (e) { + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Prompt Weather in Tokyo", + onclick: async () => { + const signal = AbortSignal.timeout(5000); + try { + const { isError } = await app.sendMessage( + { + role: "user", // Forced. + content: [ + { + type: "text", + text: "What is the weather in Tokyo?", + }, + ], + }, + { signal }, + ); + appendText(`Prompt result: ${isError ? "error" : "success"}`); + } catch (e) { + if (signal.aborted) { + appendError("Prompt request timed out"); + return; + } + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Open Link to Google", + onclick: async () => { + try { + const { isError } = await app.sendOpenLink({ + url: "https://www.google.com", + }); + appendText(`Open link result: ${isError ? "error" : "success"}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + await app.connect(transport); +}); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..ef907bf --- /dev/null +++ b/index.ts @@ -0,0 +1,3 @@ +export { App } from "./app"; +export { PostMessageTransport } from "./message-transport"; +export * from "./types"; diff --git a/message-transport.ts b/message-transport.ts new file mode 100644 index 0000000..90b8e21 --- /dev/null +++ b/message-transport.ts @@ -0,0 +1,58 @@ +import { + JSONRPCMessage, + JSONRPCMessageSchema, + MessageExtraInfo, +} from "@modelcontextprotocol/sdk/types.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.js"; +import { z } from "zod"; + +export class PostMessageTransport implements Transport { + private messageListener: ( + this: Window, + ev: WindowEventMap["message"], + ) => any | undefined; + + constructor( + private eventTarget: Window = window.parent, + private eventSource?: MessageEventSource, + ) { + this.messageListener = (event) => { + if (eventSource && event.source !== this.eventSource) { + console.error("Ignoring message from unknown source", event); + return; + } + const parsed = JSONRPCMessageSchema.safeParse(event.data); + if (parsed.success) { + console.info("[host] Parsed message", parsed.data); + this.onmessage?.(parsed.data); + } else { + console.error("Failed to parse message", parsed.error.message, event); + this.onerror?.( + new Error( + "Invalid JSON-RPC message received: " + parsed.error.message, + ), + ); + } + }; + } + async start() { + window.addEventListener("message", this.messageListener); + } + async send(message: JSONRPCMessage, options?: TransportSendOptions) { + console.info("[host] Sending message", message); + this.eventTarget.postMessage(message, "*"); + } + async close() { + window.removeEventListener("message", this.messageListener); + this.onclose?.(); + } + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + sessionId?: string; + setProtocolVersion?: (version: string) => void; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..14accbd --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "homepage": "https://gist.github.com/a9603ba2d6757d6038ce066eded4c354", + "name": "@modelcontextprotocol/ext-apps", + "version": "1.0.0", + "description": "Hey", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./react/useApp": "./src/react/useApp.tsx" + }, + "scripts": { + "start:example-server0": "tsx examples/example-server.ts", + "start:example-server": "cd ../openai-apps-sdk-examples && concurrently \"npm run serve\" \"cd pizzaz_server_node && PORT=3001 npm start\"", + "start:sandbox-proxy": "python3 sandbox_proxy.py", + "start": "npm run build && npm run start:example-server", + "start:dev": "NODE_ENV=development npm start", + "build": "tsc && tsx build.ts", + "prettier:base-cmd": "prettier -u --ignore-path ./.gitignore --ignore-path ./.prettierignore", + "prettier": "yarn prettier:base-cmd \"$(pwd)/**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check", + "prettier:fix": "yarn prettier:base-cmd \"$(pwd)/**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write --list-different" + }, + "author": "Olivier Chafik", + "devDependencies": { + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "esbuild": "~0.19.10", + "express": "^5.1.0", + "prettier": "^3.6.2", + "tsx": "^4.20.6", + "typescript": "5.7.2" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.20.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25 || ^4.0" + } +} diff --git a/react/useApp.tsx b/react/useApp.tsx new file mode 100644 index 0000000..43a8c3a --- /dev/null +++ b/react/useApp.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { Implementation } from "@modelcontextprotocol/sdk/types.js"; +import { Client } from "@modelcontextprotocol/sdk/client"; +import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; +export * from "../app"; + +export interface UseAppOptions { + appInfo: Implementation; + capabilities: McpUiAppCapabilities; + /** + * Called after client is created but before connection. + * Use this to register request/notification handlers via + * client.setRequestHandler() and client.setNotificationHandler(). + */ + onAppCreated?: (app: App) => void; +} + +export interface AppState { + app: App | null; + isConnected: boolean; + error: Error | null; +} + +export function useApp({ + appInfo, + capabilities, + onAppCreated, +}: UseAppOptions): AppState { + const [app, setApp] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + async function connect() { + try { + const transport = new PostMessageTransport(window.parent); + const app = new App(appInfo, capabilities); + + // Register handlers BEFORE connecting + onAppCreated?.(app); + + await app.connect(transport); + + if (mounted) { + setApp(app); + setIsConnected(true); + setError(null); + } + } catch (error) { + if (mounted) { + setApp(null); + setIsConnected(false); + setError( + error instanceof Error ? error : new Error("Failed to connect"), + ); + } + } + } + + connect(); + + return () => { + mounted = false; + }; + }, []); // Intentionally not including options to avoid reconnection + + return { app, isConnected, error }; +} diff --git a/react/useAutoResize.ts b/react/useAutoResize.ts new file mode 100644 index 0000000..ce39c75 --- /dev/null +++ b/react/useAutoResize.ts @@ -0,0 +1,20 @@ +import { useEffect, RefObject } from "react"; +import { App } from "../app"; + +/** + * Custom hook that automatically reports size changes to the parent window. + * + * @param client - MCP UI client for sending size notifications + */ +export function useAutoResize( + app: App | null, + elementRef?: RefObject, +) { + useEffect(() => { + if (!app) { + return; + } + + return app.setupSizeChangeNotifications(); + }, [app, elementRef]); +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..d02458d --- /dev/null +++ b/src/app.ts @@ -0,0 +1,209 @@ +import { + type RequestOptions, + Protocol, + ProtocolOptions, +} from "@modelcontextprotocol/sdk/shared/protocol.js"; + +import { + CallToolRequest, + CallToolResult, + CallToolResultSchema, + Implementation, + LoggingMessageNotification, + Notification, + PingRequestSchema, + Request, + Result, +} from "@modelcontextprotocol/sdk/types.js"; +import { + LATEST_PROTOCOL_VERSION, + McpUiAppCapabilities, + McpUiHostCapabilities, + McpUiHostInfo, + McpUiInitializedNotification, + McpUiInitializeRequest, + McpUiInitializeResultSchema, + McpUiMessageRequest, + McpUiMessageResultSchema, + McpUiOpenLinkRequest, + McpUiOpenLinkResultSchema, + McpUiSizeChangeNotification, +} from "./types"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +export { PostMessageTransport } from "./message-transport.js"; +export * from "./types"; + +type AppOptions = ProtocolOptions & { + autoResize?: boolean; +}; + +export class App extends Protocol { + private _hostCapabilities?: McpUiHostCapabilities; + private _hostInfo?: McpUiHostInfo; + + constructor( + private _appInfo: Implementation, + private _capabilities: McpUiAppCapabilities = {}, + private options: AppOptions = { autoResize: true }, + ) { + super(options); + + this.setRequestHandler(PingRequestSchema, (request) => { + console.log("Received ping:", request.params); + return {}; + }); + } + + assertCapabilityForMethod(method: Request["method"]): void { + // TODO + } + assertRequestHandlerCapability(method: Request["method"]): void { + switch (method) { + case "tools/call": + case "tools/list": + if (!this._capabilities.tools) { + throw new Error( + `Client does not support tool capability (required for ${method})`, + ); + } + return; + case "ping": + return; + default: + throw new Error(`No handler for method ${method} registered`); + } + } + assertNotificationCapability(method: Notification["method"]): void { + // TODO + } + + async callServerTool( + params: CallToolRequest["params"], + options?: RequestOptions, + ): Promise { + return await this.request( + { method: "tools/call", params }, + CallToolResultSchema, + options, + ); + } + + sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) { + return this.request( + { + method: "ui/message", + params, + }, + McpUiMessageResultSchema, + options, + ); + } + + sendLog(params: LoggingMessageNotification["params"]) { + return this.notification({ + method: "notifications/message", + params, + }); + } + + sendOpenLink( + params: McpUiOpenLinkRequest["params"], + options?: RequestOptions, + ) { + return this.request( + { + method: "ui/open-link", + params, + }, + McpUiOpenLinkResultSchema, + options, + ); + } + + sendSizeChange(params: McpUiSizeChangeNotification["params"]) { + return this.notification({ + method: "ui/notifications/size-change", + params, + }); + } + + setupSizeChangeNotifications() { + const sendBodySizeChange = () => { + let rafId: number | null = null; + + // Debounce using requestAnimationFrame to avoid duplicate messages + // when both documentElement and body fire resize events + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + const { body, documentElement: html } = document; + + const bodyStyle = getComputedStyle(body); + const htmlStyle = getComputedStyle(html); + + const width = body.scrollWidth; + const height = + body.scrollHeight + + (parseFloat(bodyStyle.borderTop) || 0) + + (parseFloat(bodyStyle.borderBottom) || 0) + + (parseFloat(htmlStyle.borderTop) || 0) + + (parseFloat(htmlStyle.borderBottom) || 0); + + this.sendSizeChange({ width, height }); + rafId = null; + }); + }; + + sendBodySizeChange(); + + const resizeObserver = new ResizeObserver(sendBodySizeChange); + // Observe both html and body to catch all size changes + resizeObserver.observe(document.documentElement); + resizeObserver.observe(document.body); + + return () => resizeObserver.disconnect(); + } + + override async connect( + transport: Transport, + options?: RequestOptions, + ): Promise { + await super.connect(transport); + + try { + const result = await this.request( + { + method: "ui/initialize", + params: { + appCapabilities: this._capabilities, + appInfo: this._appInfo, + protocolVersion: LATEST_PROTOCOL_VERSION, + }, + }, + McpUiInitializeResultSchema, + options, + ); + + if (result === undefined) { + throw new Error(`Server sent invalid initialize result: ${result}`); + } + + this._hostCapabilities = result.hostCapabilities; + this._hostInfo = result.hostInfo; + + await this.notification({ + method: "ui/notifications/initialized", + }); + + if (this.options?.autoResize) { + this.setupSizeChangeNotifications(); + } + } catch (error) { + // Disconnect if initialization fails. + void this.close(); + throw error; + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ef907bf --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export { App } from "./app"; +export { PostMessageTransport } from "./message-transport"; +export * from "./types"; diff --git a/src/message-transport.ts b/src/message-transport.ts new file mode 100644 index 0000000..90b8e21 --- /dev/null +++ b/src/message-transport.ts @@ -0,0 +1,58 @@ +import { + JSONRPCMessage, + JSONRPCMessageSchema, + MessageExtraInfo, +} from "@modelcontextprotocol/sdk/types.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.js"; +import { z } from "zod"; + +export class PostMessageTransport implements Transport { + private messageListener: ( + this: Window, + ev: WindowEventMap["message"], + ) => any | undefined; + + constructor( + private eventTarget: Window = window.parent, + private eventSource?: MessageEventSource, + ) { + this.messageListener = (event) => { + if (eventSource && event.source !== this.eventSource) { + console.error("Ignoring message from unknown source", event); + return; + } + const parsed = JSONRPCMessageSchema.safeParse(event.data); + if (parsed.success) { + console.info("[host] Parsed message", parsed.data); + this.onmessage?.(parsed.data); + } else { + console.error("Failed to parse message", parsed.error.message, event); + this.onerror?.( + new Error( + "Invalid JSON-RPC message received: " + parsed.error.message, + ), + ); + } + }; + } + async start() { + window.addEventListener("message", this.messageListener); + } + async send(message: JSONRPCMessage, options?: TransportSendOptions) { + console.info("[host] Sending message", message); + this.eventTarget.postMessage(message, "*"); + } + async close() { + window.removeEventListener("message", this.messageListener); + this.onclose?.(); + } + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + sessionId?: string; + setProtocolVersion?: (version: string) => void; +} diff --git a/src/peer.ts_ b/src/peer.ts_ new file mode 100644 index 0000000..495b540 --- /dev/null +++ b/src/peer.ts_ @@ -0,0 +1,351 @@ +// import { +// z, +// ZodRawShape, +// ZodLiteral, +// ZodObject, +// ZodString, +// ZodTypeAny, +// ZodType, +// ZodTypeDef, +// ZodOptional, +// } from "zod"; +// import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +// import { +// // ToolCallback, +// RegisteredTool, +// } from "@modelcontextprotocol/sdk/server/mcp.js"; +// import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +// import { +// Protocol, +// type RequestOptions, +// type RequestHandlerExtra, +// } from "@modelcontextprotocol/sdk/shared/protocol.js"; + +// import { +// CallToolRequestSchema, +// CallToolResult, +// CallToolResultSchema, +// CancelledNotificationSchema, +// ClientCapabilities, +// ClientCapabilitiesSchema, +// Request, +// Implementation, +// JSONRPCError, +// JSONRPCMessage, +// JSONRPCMessageSchema, +// JSONRPCNotification, +// JSONRPCResponse, +// ListPromptsRequestSchema, +// ListPromptsResultSchema, +// ListResourcesRequestSchema, +// ListResourcesResultSchema, +// ListResourceTemplatesRequestSchema, +// ListResourceTemplatesResultSchema, +// LoggingMessageNotification, +// LoggingMessageNotificationSchema, +// MessageExtraInfo, +// Notification, +// PromptListChangedNotificationSchema, +// RequestId, +// ResourceListChangedNotificationSchema, +// Result, +// ServerCapabilities, +// ServerCapabilitiesSchema, +// ServerNotification, +// ServerRequest, +// ToolAnnotations, +// ToolListChangedNotificationSchema, +// } from "@modelcontextprotocol/sdk/types.js"; +// import { +// Transport, +// TransportSendOptions, +// } from "@modelcontextprotocol/sdk/shared/transport.js"; +// import { +// McpUiToolInputNotification, +// McpUiToolInputNotificationSchema, +// McpUiToolResultNotification, +// McpUiToolResultNotificationSchema, +// McpUiHostCapabilities, +// McpUiHostInfo, +// McpUiInitializedNotification, +// McpUiInitializeRequest, +// McpUiInitializeResultSchema, +// McpUiIntentRequest, +// McpUiIntentRequestSchema, +// McpUiIntentResultSchema, +// McpUiRenderDataRequest, +// McpUiRenderDataRequestSchema, +// McpUiMessageRequest, +// McpUiMessageRequestSchema, +// McpUiMessageResult, +// McpUiMessageResultSchema, +// McpUiOpenLinkRequest, +// McpUiOpenLinkRequestSchema, +// McpUiOpenLinkResultSchema, +// McpUiRenderDataResultSchema, +// McpUiSizeChangeNotification, +// McpUiSizeChangeNotificationSchema, +// } from "./types"; + +// export { PostMessageTransport } from "./message-transport.js"; + +// if (false) { +// let server: Server; +// let client: Client; +// } + +// const McpUiInitializeRequestSchema = z.object({ +// method: z.literal("ui/initialize"), +// params: z.object({ +// name: z.string(), +// version: z.string(), +// capabilities: ServerCapabilitiesSchema.extend( +// ClientCapabilitiesSchema.shape, +// ), +// }), +// }); + +// /** +// * Converts a provided Zod schema to a Zod object if it is a ZodRawShape, +// * otherwise returns the schema as is. +// */ +// function getZodSchemaObject( +// schema: ZodRawShape | ZodType | undefined, +// ): ZodType | undefined { +// if (!schema) { +// return undefined; +// } + +// if (isZodRawShape(schema)) { +// return z.object(schema); +// } + +// return schema; +// } + +// export class McpUiGuest extends Protocol { +// private _registeredHostTools: Record = {}; + +// assertCapabilityForMethod(method: Request["method"]): void {} +// assertRequestHandlerCapability(method: Request["method"]): void {} +// assertNotificationCapability(method: Notification["method"]): void {} + +// async callServerTool( +// name: string, +// argsSchema: ZodTypeAny, +// args: unknown, +// options?: RequestOptions, +// ) { +// //: Promise { +// } +// async addUiToChat( +// resourceUri: string, +// state: Record, +// ): Promise {} +// // In future, could expose callHostTool + +// registerHostTool< +// InputArgs extends ZodRawShape | ZodType, +// OutputArgs extends ZodRawShape | ZodType, +// >( +// name: string, +// config: { +// title?: string; +// description?: string; +// inputSchema?: InputArgs; +// outputSchema?: OutputArgs; +// annotations?: ToolAnnotations; +// _meta?: Record; +// }, +// cb: ToolCallback, +// ): RegisteredTool { +// if (this._registeredHostTools[name]) { +// throw new Error(`Tool ${name} is already registered on host`); +// } + +// const { +// title, +// description, +// inputSchema, +// outputSchema, +// annotations, +// _meta, +// } = config; + +// return this._createRegisteredTool( +// name, +// title, +// description, +// inputSchema, +// outputSchema, +// annotations, +// _meta, +// cb as ToolCallback, +// ); +// } + +// private _createRegisteredTool( +// name: string, +// title: string | undefined, +// description: string | undefined, +// inputSchema: ZodRawShape | ZodType | undefined, +// outputSchema: ZodRawShape | ZodType | undefined, +// annotations: ToolAnnotations | undefined, +// _meta: Record | undefined, +// callback: ToolCallback, +// ): RegisteredTool { +// const registeredTool: RegisteredTool = { +// title, +// description, +// inputSchema: getZodSchemaObject(inputSchema) as any, +// outputSchema: getZodSchemaObject(outputSchema) as any, +// annotations, +// _meta, +// callback, +// enabled: true, +// disable: () => registeredTool.update({ enabled: false }), +// enable: () => registeredTool.update({ enabled: true }), +// remove: () => registeredTool.update({ name: null }), +// update: (updates) => { +// if (typeof updates.name !== "undefined" && updates.name !== name) { +// delete this._registeredHostTools[name]; +// if (updates.name) +// this._registeredHostTools[updates.name] = registeredTool; +// } +// if (typeof updates.title !== "undefined") +// registeredTool.title = updates.title; +// if (typeof updates.description !== "undefined") +// registeredTool.description = updates.description; +// if (typeof updates.paramsSchema !== "undefined") +// registeredTool.inputSchema = z.object(updates.paramsSchema); +// if (typeof updates.callback !== "undefined") +// registeredTool.callback = updates.callback; +// if (typeof updates.annotations !== "undefined") +// registeredTool.annotations = updates.annotations; +// if (typeof updates._meta !== "undefined") +// registeredTool._meta = updates._meta; +// if (typeof updates.enabled !== "undefined") +// registeredTool.enabled = updates.enabled; +// // this.sendToolListChanged(); +// }, +// }; +// this._registeredHostTools[name] = registeredTool; + +// return registeredTool; +// } + +// sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) { +// return this.request( +// { +// method: "ui/message", +// params, +// }, +// McpUiMessageResultSchema, +// options, +// ); +// } + +// sendLog(params: LoggingMessageNotification["params"]) { +// return this.notification({ +// method: "notifications/message", +// params, +// }); +// } + +// sendOpenLink( +// params: McpUiOpenLinkRequest["params"], +// options?: RequestOptions, +// ) { +// return this.request( +// { +// method: "ui/open-link", +// params, +// }, +// McpUiOpenLinkResultSchema, +// options, +// ); +// } + +// sendSizeChange(params: McpUiSizeChangeNotification["params"]) { +// return this.notification({ +// method: "ui/size-change", +// params, +// }); +// } + +// private _hostCapabilities?: McpUiHostCapabilities; +// private _hostInfo?: McpUiHostInfo; + +// override async connect( +// transport: Transport, +// options?: RequestOptions, +// ): Promise { +// await super.connect(transport); + +// try { +// const result = await this.request( +// { +// method: "ui/initialize", +// params: { +// // TODO: protocolVersion: LATEST_PROTOCOL_VERSION, +// }, +// }, +// McpUiInitializeResultSchema, +// options, +// ); + +// if (result === undefined) { +// throw new Error(`Server sent invalid initialize result: ${result}`); +// } + +// this._hostCapabilities = result.hostCapabilities; +// this._hostInfo = result.hostInfo; + +// await this.notification({ +// method: "ui/notifications/initialized", +// }); +// } catch (error) { +// // Disconnect if initialization fails. +// void this.close(); +// throw error; +// } +// } +// } + +// export type ToolCallback< +// Args extends undefined | ZodRawShape | ZodType = undefined, +// > = Args extends ZodRawShape +// ? ( +// args: z.objectOutputType, +// extra: RequestHandlerExtra, +// ) => CallToolResult | Promise +// : Args extends ZodType +// ? ( +// args: T, +// extra: RequestHandlerExtra, +// ) => CallToolResult | Promise +// : ( +// extra: RequestHandlerExtra, +// ) => CallToolResult | Promise; + +// // Helper to check if an object is a Zod schema (ZodRawShape) +// function isZodRawShape(obj: unknown): obj is ZodRawShape { +// if (typeof obj !== "object" || obj === null) return false; + +// const isEmptyObject = Object.keys(obj).length === 0; + +// // Check if object is empty or at least one property is a ZodType instance +// // Note: use heuristic check to avoid instanceof failure across different Zod versions +// return isEmptyObject || Object.values(obj as object).some(isZodTypeLike); +// } + +// function isZodTypeLike(value: unknown): value is ZodType { +// return ( +// value !== null && +// typeof value === "object" && +// "parse" in value && +// typeof value.parse === "function" && +// "safeParse" in value && +// typeof value.safeParse === "function" +// ); +// } diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx new file mode 100644 index 0000000..43a8c3a --- /dev/null +++ b/src/react/useApp.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { Implementation } from "@modelcontextprotocol/sdk/types.js"; +import { Client } from "@modelcontextprotocol/sdk/client"; +import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; +export * from "../app"; + +export interface UseAppOptions { + appInfo: Implementation; + capabilities: McpUiAppCapabilities; + /** + * Called after client is created but before connection. + * Use this to register request/notification handlers via + * client.setRequestHandler() and client.setNotificationHandler(). + */ + onAppCreated?: (app: App) => void; +} + +export interface AppState { + app: App | null; + isConnected: boolean; + error: Error | null; +} + +export function useApp({ + appInfo, + capabilities, + onAppCreated, +}: UseAppOptions): AppState { + const [app, setApp] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + async function connect() { + try { + const transport = new PostMessageTransport(window.parent); + const app = new App(appInfo, capabilities); + + // Register handlers BEFORE connecting + onAppCreated?.(app); + + await app.connect(transport); + + if (mounted) { + setApp(app); + setIsConnected(true); + setError(null); + } + } catch (error) { + if (mounted) { + setApp(null); + setIsConnected(false); + setError( + error instanceof Error ? error : new Error("Failed to connect"), + ); + } + } + } + + connect(); + + return () => { + mounted = false; + }; + }, []); // Intentionally not including options to avoid reconnection + + return { app, isConnected, error }; +} diff --git a/src/react/useAutoResize.ts b/src/react/useAutoResize.ts new file mode 100644 index 0000000..ce39c75 --- /dev/null +++ b/src/react/useAutoResize.ts @@ -0,0 +1,20 @@ +import { useEffect, RefObject } from "react"; +import { App } from "../app"; + +/** + * Custom hook that automatically reports size changes to the parent window. + * + * @param client - MCP UI client for sending size notifications + */ +export function useAutoResize( + app: App | null, + elementRef?: RefObject, +) { + useEffect(() => { + if (!app) { + return; + } + + return app.setupSizeChangeNotifications(); + }, [app, elementRef]); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d216b31 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,236 @@ +import { + CallToolResultSchema, + ContentBlockSchema, + EmptyResultSchema, + ImplementationSchema, + RequestId, + RequestIdSchema, + RequestSchema, + Tool, + ToolSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +export const LATEST_PROTOCOL_VERSION = "2025-11-21"; + +export const McpUiOpenLinkRequestSchema = RequestSchema.extend({ + method: z.literal("ui/open-link"), + params: z.object({ + url: z.string().url(), + }), +}); +export type McpUiOpenLinkRequest = z.infer; + +export const McpUiOpenLinkResultSchema = z.object({ + isError: z.boolean(), +}); +export type McpUiOpenLinkResult = z.infer; + +export const McpUiMessageRequestSchema = RequestSchema.extend({ + method: z.literal("ui/message"), + params: z.object({ + role: z.literal("user"), + content: z.array(ContentBlockSchema), + }), +}); +export type McpUiMessageRequest = z.infer; + +export const McpUiMessageResultSchema = z.object({ + // Note: we don't return the result from follow up messages as they might leak info from the chat. + // We do tell the caller if it errored, though. + isError: z.boolean(), +}); +export type McpUiMessageResult = z.infer; + +// McpUiIframeReadyNotification removed - replaced by standard MCP initialization +// The SDK's oninitialized callback now handles the ready signal + +export const McpUiSandboxProxyReadyNotificationSchema = z.object({ + method: z.literal("ui/notifications/sandbox-proxy-ready"), + params: z.object({}), +}); +export type McpUiSandboxProxyReadyNotification = z.infer< + typeof McpUiSandboxProxyReadyNotificationSchema +>; + +export const McpUiSandboxResourceReadyNotificationSchema = z.object({ + method: z.literal("ui/notifications/sandbox-resource-ready"), + params: z.object({ + html: z.string(), //ReadResourceResultSchema, + sandbox: z.string().optional(), + }), +}); +export type McpUiSandboxResourceReadyNotification = z.infer< + typeof McpUiSandboxResourceReadyNotificationSchema +>; + +// Fired by the iframe when its body changes, and by the host when the viewport size changes. +export const McpUiSizeChangeNotificationSchema = z.object({ + method: z.literal("ui/notifications/size-change"), + params: z.object({ + width: z.number().optional(), + height: z.number().optional(), + }), +}); +export type McpUiSizeChangeNotification = z.infer< + typeof McpUiSizeChangeNotificationSchema +>; + +export const McpUiToolInputNotificationSchema = z.object({ + method: z.literal("ui/notifications/tool-inputs"), + params: z.object({ + arguments: z.record(z.unknown()).optional(), + }), +}); +export type McpUiToolInputNotification = z.infer< + typeof McpUiToolInputNotificationSchema +>; + +export const McpUiToolInputPartialNotificationSchema = z.object({ + method: z.literal("ui/notifications/tool-inputs-partial"), + params: z.object({ + arguments: z.record(z.unknown()).optional(), + }), +}); +export type McpUiToolInputPartialNotification = z.infer< + typeof McpUiToolInputPartialNotificationSchema +>; + +// Fired once both tool call returned *AND* host received ui/ui-lifecycle-iframe-ready. +export const McpUiToolResultNotificationSchema = z.object({ + method: z.literal("ui/notifications/tool-result"), + params: CallToolResultSchema, +}); +export type McpUiToolResultNotification = z.infer< + typeof McpUiToolResultNotificationSchema +>; + +export const McpUiHostContextSchema = z.object({ + toolInfo: z + .object({ + // Metadata of the tool call that instantiated the App + id: RequestIdSchema, // JSON-RPC id of the tools/call request + tool: ToolSchema, // contains name, inputSchema, etc… + }) + .optional(), + theme: z.enum(["light", "dark", "system"]).optional(), + displayMode: z.enum(["inline", "fullscreen", "pip", "carousel"]).optional(), + availableDisplayModes: z.array(z.string()).optional(), + viewport: z + .object({ + width: z.number(), + height: z.number(), + maxHeight: z.number().optional(), + maxWidth: z.number().optional(), + }) + .optional(), + locale: z.string().optional(), // BCP 47, e.g., "en-US" + timeZone: z.string().optional(), // IANA, e.g., "America/New_York" + userAgent: z.string().optional(), + platform: z.enum(["web", "desktop", "mobile"]).optional(), + deviceCapabilities: z + .object({ + touch: z.boolean().optional(), + hover: z.boolean().optional(), + }) + .optional(), + safeAreaInsets: z + .object({ + top: z.number(), + right: z.number(), + bottom: z.number(), + left: z.number(), + }) + .optional(), +}); +export type McpUiHostContext = z.infer; + +export const McpUiHostContextChangedNotificationSchema = z.object({ + method: z.literal("ui/notifications/host-context-changed"), + params: McpUiHostContextSchema, +}); +export type McpUiHostContextChangedNotification = z.infer< + typeof McpUiHostContextChangedNotificationSchema +>; + +export const McpUiResourceTeardownRequestSchema = RequestSchema.extend({ + method: z.literal("ui/resource-teardown"), + params: z.object({}), +}); +export type McpUiResourceTeardownRequest = z.infer< + typeof McpUiResourceTeardownRequestSchema +>; + +export const McpUiResourceTeardownResultSchema = EmptyResultSchema; +export type McpUiResourceTeardownResult = z.infer< + typeof McpUiResourceTeardownResultSchema +>; + +export const McpUiHostCapabilitiesSchema = z.object({ + experimental: z.object({}).optional(), + + openLinks: z.object({}).optional(), + + serverTools: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), + + serverResources: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), + + logging: z.object({}).optional(), + + // TODO: elicitation, sampling... +}); +export type McpUiHostCapabilities = z.infer; + +export const McpUiAppCapabilitiesSchema = z.object({ + experimental: z.object({}).optional(), + + // WebMCP-style tools exposed by the app to the host + tools: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), +}); +export type McpUiAppCapabilities = z.infer; + +export const McpUiInitializeRequestSchema = RequestSchema.extend({ + method: z.literal("ui/initialize"), + params: z.object({ + appInfo: ImplementationSchema, + appCapabilities: McpUiAppCapabilitiesSchema, + protocolVersion: z.string(), + }), +}); +export type McpUiInitializeRequest = z.infer< + typeof McpUiInitializeRequestSchema +>; + +export const McpUiHostInfoSchema = z.object({ + name: z.string(), + version: z.string(), +}); +export type McpUiHostInfo = z.infer; + +export const McpUiInitializeResultSchema = z.object({ + protocolVersion: z.string(), + hostInfo: McpUiHostInfoSchema, + hostCapabilities: McpUiHostCapabilitiesSchema, + hostContext: McpUiHostContextSchema, +}); +export type McpUiInitializeResult = z.infer; + +export const McpUiInitializedNotificationSchema = z.object({ + method: z.literal("ui/notifications/initialized"), + params: z.object({}), +}); +export type McpUiInitializedNotification = z.infer< + typeof McpUiInitializedNotificationSchema +>; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..85ec789 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "jsx": "react-jsx", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "examples/**/*.ts"] +} diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..d216b31 --- /dev/null +++ b/types.ts @@ -0,0 +1,236 @@ +import { + CallToolResultSchema, + ContentBlockSchema, + EmptyResultSchema, + ImplementationSchema, + RequestId, + RequestIdSchema, + RequestSchema, + Tool, + ToolSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +export const LATEST_PROTOCOL_VERSION = "2025-11-21"; + +export const McpUiOpenLinkRequestSchema = RequestSchema.extend({ + method: z.literal("ui/open-link"), + params: z.object({ + url: z.string().url(), + }), +}); +export type McpUiOpenLinkRequest = z.infer; + +export const McpUiOpenLinkResultSchema = z.object({ + isError: z.boolean(), +}); +export type McpUiOpenLinkResult = z.infer; + +export const McpUiMessageRequestSchema = RequestSchema.extend({ + method: z.literal("ui/message"), + params: z.object({ + role: z.literal("user"), + content: z.array(ContentBlockSchema), + }), +}); +export type McpUiMessageRequest = z.infer; + +export const McpUiMessageResultSchema = z.object({ + // Note: we don't return the result from follow up messages as they might leak info from the chat. + // We do tell the caller if it errored, though. + isError: z.boolean(), +}); +export type McpUiMessageResult = z.infer; + +// McpUiIframeReadyNotification removed - replaced by standard MCP initialization +// The SDK's oninitialized callback now handles the ready signal + +export const McpUiSandboxProxyReadyNotificationSchema = z.object({ + method: z.literal("ui/notifications/sandbox-proxy-ready"), + params: z.object({}), +}); +export type McpUiSandboxProxyReadyNotification = z.infer< + typeof McpUiSandboxProxyReadyNotificationSchema +>; + +export const McpUiSandboxResourceReadyNotificationSchema = z.object({ + method: z.literal("ui/notifications/sandbox-resource-ready"), + params: z.object({ + html: z.string(), //ReadResourceResultSchema, + sandbox: z.string().optional(), + }), +}); +export type McpUiSandboxResourceReadyNotification = z.infer< + typeof McpUiSandboxResourceReadyNotificationSchema +>; + +// Fired by the iframe when its body changes, and by the host when the viewport size changes. +export const McpUiSizeChangeNotificationSchema = z.object({ + method: z.literal("ui/notifications/size-change"), + params: z.object({ + width: z.number().optional(), + height: z.number().optional(), + }), +}); +export type McpUiSizeChangeNotification = z.infer< + typeof McpUiSizeChangeNotificationSchema +>; + +export const McpUiToolInputNotificationSchema = z.object({ + method: z.literal("ui/notifications/tool-inputs"), + params: z.object({ + arguments: z.record(z.unknown()).optional(), + }), +}); +export type McpUiToolInputNotification = z.infer< + typeof McpUiToolInputNotificationSchema +>; + +export const McpUiToolInputPartialNotificationSchema = z.object({ + method: z.literal("ui/notifications/tool-inputs-partial"), + params: z.object({ + arguments: z.record(z.unknown()).optional(), + }), +}); +export type McpUiToolInputPartialNotification = z.infer< + typeof McpUiToolInputPartialNotificationSchema +>; + +// Fired once both tool call returned *AND* host received ui/ui-lifecycle-iframe-ready. +export const McpUiToolResultNotificationSchema = z.object({ + method: z.literal("ui/notifications/tool-result"), + params: CallToolResultSchema, +}); +export type McpUiToolResultNotification = z.infer< + typeof McpUiToolResultNotificationSchema +>; + +export const McpUiHostContextSchema = z.object({ + toolInfo: z + .object({ + // Metadata of the tool call that instantiated the App + id: RequestIdSchema, // JSON-RPC id of the tools/call request + tool: ToolSchema, // contains name, inputSchema, etc… + }) + .optional(), + theme: z.enum(["light", "dark", "system"]).optional(), + displayMode: z.enum(["inline", "fullscreen", "pip", "carousel"]).optional(), + availableDisplayModes: z.array(z.string()).optional(), + viewport: z + .object({ + width: z.number(), + height: z.number(), + maxHeight: z.number().optional(), + maxWidth: z.number().optional(), + }) + .optional(), + locale: z.string().optional(), // BCP 47, e.g., "en-US" + timeZone: z.string().optional(), // IANA, e.g., "America/New_York" + userAgent: z.string().optional(), + platform: z.enum(["web", "desktop", "mobile"]).optional(), + deviceCapabilities: z + .object({ + touch: z.boolean().optional(), + hover: z.boolean().optional(), + }) + .optional(), + safeAreaInsets: z + .object({ + top: z.number(), + right: z.number(), + bottom: z.number(), + left: z.number(), + }) + .optional(), +}); +export type McpUiHostContext = z.infer; + +export const McpUiHostContextChangedNotificationSchema = z.object({ + method: z.literal("ui/notifications/host-context-changed"), + params: McpUiHostContextSchema, +}); +export type McpUiHostContextChangedNotification = z.infer< + typeof McpUiHostContextChangedNotificationSchema +>; + +export const McpUiResourceTeardownRequestSchema = RequestSchema.extend({ + method: z.literal("ui/resource-teardown"), + params: z.object({}), +}); +export type McpUiResourceTeardownRequest = z.infer< + typeof McpUiResourceTeardownRequestSchema +>; + +export const McpUiResourceTeardownResultSchema = EmptyResultSchema; +export type McpUiResourceTeardownResult = z.infer< + typeof McpUiResourceTeardownResultSchema +>; + +export const McpUiHostCapabilitiesSchema = z.object({ + experimental: z.object({}).optional(), + + openLinks: z.object({}).optional(), + + serverTools: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), + + serverResources: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), + + logging: z.object({}).optional(), + + // TODO: elicitation, sampling... +}); +export type McpUiHostCapabilities = z.infer; + +export const McpUiAppCapabilitiesSchema = z.object({ + experimental: z.object({}).optional(), + + // WebMCP-style tools exposed by the app to the host + tools: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), +}); +export type McpUiAppCapabilities = z.infer; + +export const McpUiInitializeRequestSchema = RequestSchema.extend({ + method: z.literal("ui/initialize"), + params: z.object({ + appInfo: ImplementationSchema, + appCapabilities: McpUiAppCapabilitiesSchema, + protocolVersion: z.string(), + }), +}); +export type McpUiInitializeRequest = z.infer< + typeof McpUiInitializeRequestSchema +>; + +export const McpUiHostInfoSchema = z.object({ + name: z.string(), + version: z.string(), +}); +export type McpUiHostInfo = z.infer; + +export const McpUiInitializeResultSchema = z.object({ + protocolVersion: z.string(), + hostInfo: McpUiHostInfoSchema, + hostCapabilities: McpUiHostCapabilitiesSchema, + hostContext: McpUiHostContextSchema, +}); +export type McpUiInitializeResult = z.infer; + +export const McpUiInitializedNotificationSchema = z.object({ + method: z.literal("ui/notifications/initialized"), + params: z.object({}), +}); +export type McpUiInitializedNotification = z.infer< + typeof McpUiInitializedNotificationSchema +>;