From 53ce8465a77a6d862a67e27aeed6be8aeedca015 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 00:50:52 +0100 Subject: [PATCH 01/11] feat: Add tool registration and bidirectional tool support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds comprehensive tool support for MCP Apps, enabling apps to register their own tools and handle tool calls from the host. ## Changes ### App (Guest UI) side: - Add `registerTool()` method for registering tools with input/output schemas - Add `oncalltool` setter for handling tool call requests from host - Add `onlisttools` setter for handling tool list requests from host - Add `sendToolListChanged()` for notifying host of tool updates - Registered tools support enable/disable/update/remove operations ### AppBridge (Host) side: - Add `sendCallTool()` method for calling tools on the app - Add `sendListTools()` method for listing available app tools - Fix: Use correct ListToolsResultSchema (was ListToolsRequestSchema) ### Tests: - Add comprehensive tests for tool registration lifecycle - Add tests for input/output schema validation - Add tests for bidirectional tool call communication - Add tests for tool list change notifications - All 27 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app-bridge.test.ts | 329 ++++++++++++++++++++++++++++++++++++++++- src/app-bridge.ts | 20 +++ src/app.ts | 121 ++++++++++++++- 3 files changed, 467 insertions(+), 3 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index b2f943b5..4ec3c9b4 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { + EmptyResultSchema, + CallToolResultSchema, + ListToolsResultSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod/v4"; import { App } from "./app"; import { AppBridge, type McpUiHostCapabilities } from "./app-bridge"; @@ -294,4 +299,326 @@ describe("App <-> AppBridge integration", () => { expect(result).toEqual({}); }); }); + + describe("App tool registration", () => { + beforeEach(async () => { + await bridge.connect(bridgeTransport); + }); + + it("registerTool creates a registered tool", async () => { + const InputSchema = z.object({ name: z.string() }) as any; + const OutputSchema = z.object({ greeting: z.string() }) as any; + + const tool = app.registerTool( + "greet", + { + title: "Greet User", + description: "Greets a user by name", + inputSchema: InputSchema, + outputSchema: OutputSchema, + }, + async (args: any) => ({ + content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], + structuredContent: { greeting: `Hello, ${args.name}!` }, + }), + ); + + expect(tool.title).toBe("Greet User"); + expect(tool.description).toBe("Greets a user by name"); + expect(tool.enabled).toBe(true); + }); + + it("registered tool can be enabled and disabled", async () => { + await app.connect(appTransport); + + const tool = app.registerTool( + "test-tool", + { + description: "Test tool", + }, + async (_extra: any) => ({ content: [] }), + ); + + expect(tool.enabled).toBe(true); + + tool.disable(); + expect(tool.enabled).toBe(false); + + tool.enable(); + expect(tool.enabled).toBe(true); + }); + + it("registered tool can be updated", async () => { + await app.connect(appTransport); + + const tool = app.registerTool( + "test-tool", + { + description: "Original description", + }, + async (_extra: any) => ({ content: [] }), + ); + + expect(tool.description).toBe("Original description"); + + tool.update({ description: "Updated description" }); + expect(tool.description).toBe("Updated description"); + }); + + it("registered tool can be removed", async () => { + await app.connect(appTransport); + + const tool = app.registerTool( + "test-tool", + { + description: "Test tool", + }, + async (_extra: any) => ({ content: [] }), + ); + + tool.remove(); + // Tool should no longer be registered (internal check) + }); + + it("tool throws error when disabled and called", async () => { + await app.connect(appTransport); + + const tool = app.registerTool( + "test-tool", + { + description: "Test tool", + }, + async (_extra: any) => ({ content: [] }), + ); + + tool.disable(); + + const mockExtra = { + signal: new AbortController().signal, + requestId: "test", + sendNotification: async () => {}, + sendRequest: async () => ({}), + } as any; + + await expect( + (tool.callback as any)(mockExtra), + ).rejects.toThrow("Tool test-tool is disabled"); + }); + + it("tool validates input schema", async () => { + const InputSchema = z.object({ name: z.string() }) as any; + + const tool = app.registerTool( + "greet", + { + inputSchema: InputSchema, + }, + async (args: any) => ({ + content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], + }), + ); + + // Create a mock RequestHandlerExtra + const mockExtra = { + signal: new AbortController().signal, + requestId: "test", + sendNotification: async () => {}, + sendRequest: async () => ({}), + } as any; + + // Valid input should work + await expect( + (tool.callback as any)({ name: "Alice" }, mockExtra), + ).resolves.toBeDefined(); + + // Invalid input should fail + await expect( + (tool.callback as any)({ invalid: "field" }, mockExtra), + ).rejects.toThrow("Invalid input for tool greet"); + }); + + it("tool validates output schema", async () => { + const OutputSchema = z.object({ greeting: z.string() }) as any; + + const tool = app.registerTool( + "greet", + { + outputSchema: OutputSchema, + }, + async (_extra: any) => ({ + content: [{ type: "text" as const, text: "Hello!" }], + structuredContent: { greeting: "Hello!" }, + }), + ); + + // Create a mock RequestHandlerExtra + const mockExtra = { + signal: new AbortController().signal, + requestId: "test", + sendNotification: async () => {}, + sendRequest: async () => ({}), + } as any; + + // Valid output should work + await expect( + (tool.callback as any)(mockExtra), + ).resolves.toBeDefined(); + }); + + it("tool enable/disable/update/remove trigger sendToolListChanged", async () => { + await app.connect(appTransport); + + const tool = app.registerTool( + "test-tool", + { + description: "Test tool", + }, + async (_extra: any) => ({ content: [] }), + ); + + // The methods should not throw when connected + expect(() => tool.disable()).not.toThrow(); + expect(() => tool.enable()).not.toThrow(); + expect(() => tool.update({ description: "Updated" })).not.toThrow(); + expect(() => tool.remove()).not.toThrow(); + }); + }); + + describe("AppBridge -> App tool requests", () => { + beforeEach(async () => { + await bridge.connect(bridgeTransport); + }); + + it("bridge.sendCallTool calls app.oncalltool handler", async () => { + // App needs tool capabilities to handle tool calls + const appCapabilities = { tools: {} }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + const receivedCalls: unknown[] = []; + + app.oncalltool = async (params) => { + receivedCalls.push(params); + return { + content: [{ type: "text", text: `Executed: ${params.name}` }], + }; + }; + + await app.connect(appTransport); + + const result = await bridge.sendCallTool({ + name: "test-tool", + arguments: { foo: "bar" }, + }); + + expect(receivedCalls).toHaveLength(1); + expect(receivedCalls[0]).toMatchObject({ + name: "test-tool", + arguments: { foo: "bar" }, + }); + expect(result.content).toEqual([ + { type: "text", text: "Executed: test-tool" }, + ]); + }); + + it("bridge.sendListTools calls app.onlisttools handler", async () => { + // App needs tool capabilities to handle tool list requests + const appCapabilities = { tools: {} }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + const receivedCalls: unknown[] = []; + + app.onlisttools = async (params, extra) => { + receivedCalls.push(params); + return { + tools: [ + { + name: "tool1", + description: "First tool", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "tool2", + description: "Second tool", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "tool3", + description: "Third tool", + inputSchema: { type: "object", properties: {} }, + }, + ], + }; + }; + + await app.connect(appTransport); + + const result = await bridge.sendListTools({}); + + expect(receivedCalls).toHaveLength(1); + expect(result.tools).toHaveLength(3); + expect(result.tools[0].name).toBe("tool1"); + expect(result.tools[1].name).toBe("tool2"); + expect(result.tools[2].name).toBe("tool3"); + }); + }); + + describe("App tool capabilities", () => { + it("App with tool capabilities can handle tool calls", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + const receivedCalls: unknown[] = []; + app.oncalltool = async (params) => { + receivedCalls.push(params); + return { + content: [{ type: "text", text: "Success" }], + }; + }; + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + await bridge.sendCallTool({ + name: "test-tool", + arguments: {}, + }); + + expect(receivedCalls).toHaveLength(1); + }); + + it("registered tool is invoked via oncalltool", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + const tool = app.registerTool( + "greet", + { + description: "Greets user", + inputSchema: z.object({ name: z.string() }) as any, + }, + async (args: any) => ({ + content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], + }), + ); + + app.oncalltool = async (params, extra) => { + if (params.name === "greet") { + return await (tool.callback as any)(params.arguments || {}, extra); + } + throw new Error(`Unknown tool: ${params.name}`); + }; + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + const result = await bridge.sendCallTool({ + name: "greet", + arguments: { name: "Alice" }, + }); + + expect(result.content).toEqual([ + { type: "text", text: "Hello, Alice!" }, + ]); + }); + }); }); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index c9edafe2..9e52b240 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -3,6 +3,7 @@ import { ZodLiteral, ZodObject } from "zod/v4"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { + CallToolRequest, CallToolRequestSchema, CallToolResultSchema, Implementation, @@ -12,6 +13,9 @@ import { ListResourcesResultSchema, ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema, + ListToolsRequest, + ListToolsRequestSchema, + ListToolsResultSchema, LoggingMessageNotification, LoggingMessageNotificationSchema, Notification, @@ -796,6 +800,22 @@ export class AppBridge extends Protocol { }); } + sendCallTool(params: CallToolRequest["params"], options?: RequestOptions) { + return this.request( + { method: "tools/call", params }, + CallToolResultSchema, + options, + ); + } + + sendListTools(params: ListToolsRequest["params"], options?: RequestOptions) { + return this.request( + { method: "tools/list", params }, + ListToolsResultSchema, + options, + ); + } + /** * Connect to the Guest UI via transport and set up message forwarding. * diff --git a/src/app.ts b/src/app.ts index c6fc931b..50507a54 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,6 @@ import { type RequestOptions, + mergeCapabilities, Protocol, ProtocolOptions, } from "@modelcontextprotocol/sdk/shared/protocol.js"; @@ -17,6 +18,8 @@ import { PingRequestSchema, Request, Result, + ToolAnnotations, + ToolListChangedNotification, } from "@modelcontextprotocol/sdk/types.js"; import { LATEST_PROTOCOL_VERSION, @@ -40,6 +43,12 @@ import { McpUiToolResultNotificationSchema, } from "./types"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { safeParseAsync, ZodRawShape } from "zod/v4"; +import { + RegisteredTool, + ToolCallback, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ZodSchema } from "zod"; export { PostMessageTransport } from "./message-transport.js"; export * from "./types"; @@ -181,6 +190,7 @@ type RequestHandlerExtra = Parameters< export class App extends Protocol { private _hostCapabilities?: McpUiHostCapabilities; private _hostInfo?: Implementation; + private _registeredTools: { [name: string]: RegisteredTool } = {}; /** * Create a new MCP App instance. @@ -211,6 +221,111 @@ export class App extends Protocol { }); } + private registerCapabilities(capabilities: McpUiAppCapabilities): void { + if (this.transport) { + throw new Error( + "Cannot register capabilities after transport is established", + ); + } + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + } + + registerTool< + OutputArgs extends ZodSchema, + InputArgs extends undefined | ZodSchema = undefined, + >( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback, + ): RegisteredTool { + const app = this; + const registeredTool: RegisteredTool = { + title: config.title, + description: config.description, + inputSchema: config.inputSchema, + outputSchema: config.outputSchema, + annotations: config.annotations, + _meta: config._meta, + enabled: true, + enable(): void { + this.enabled = true; + app.sendToolListChanged(); + }, + disable(): void { + this.enabled = false; + app.sendToolListChanged(); + }, + update(updates) { + Object.assign(this, updates); + app.sendToolListChanged(); + }, + remove() { + delete app._registeredTools[name]; + app.sendToolListChanged(); + }, + callback: (async (args: any, extra: RequestHandlerExtra) => { + if (!registeredTool.enabled) { + throw new Error(`Tool ${name} is disabled`); + } + if (config.inputSchema) { + const parseResult = await safeParseAsync( + config.inputSchema as any, + args, + ); + if (!parseResult.success) { + throw new Error( + `Invalid input for tool ${name}: ${parseResult.error}`, + ); + } + args = parseResult.data; + } + const result = await cb(args, extra); + if (config.outputSchema) { + const parseResult = await safeParseAsync( + config.outputSchema as any, + result.structuredContent, + ); + if (!parseResult.success) { + throw new Error( + `Invalid output for tool ${name}: ${parseResult.error}`, + ); + } + return parseResult.data; + } + return result; + }) as any, + }; + + this._registeredTools[name] = registeredTool; + + this.ensureToolHandlersInitialized(); + return registeredTool; + } + + private _toolHandlersInitialized = false; + private ensureToolHandlersInitialized(): void { + if (this._toolHandlersInitialized) { + return; + } + this._toolHandlersInitialized = true; + } + + async sendToolListChanged( + params: ToolListChangedNotification["params"] = {}, + ): Promise { + await this.notification({ + method: "notifications/tools/list_changed", + params, + }); + } + /** * Get the host's capabilities discovered during initialization. * @@ -486,7 +601,9 @@ export class App extends Protocol { * ```typescript * app.onlisttools = async (params, extra) => { * return { - * tools: ["calculate", "convert", "format"] + * tools: [ + * { name: "calculate", description: "Calculator", inputSchema: { type: "object", properties: {} } } + * ] * }; * }; * ``` @@ -498,7 +615,7 @@ export class App extends Protocol { callback: ( params: ListToolsRequest["params"], extra: RequestHandlerExtra, - ) => Promise<{ tools: string[] }>, + ) => Promise, ) { this.setRequestHandler(ListToolsRequestSchema, (request, extra) => callback(request.params, extra), From 01b27572e2d59f7130d2cba59396f9d557516ea5 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 00:51:14 +0100 Subject: [PATCH 02/11] chore: Apply prettier formatting to test file --- src/app-bridge.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 4ec3c9b4..4d08a820 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -400,9 +400,9 @@ describe("App <-> AppBridge integration", () => { sendRequest: async () => ({}), } as any; - await expect( - (tool.callback as any)(mockExtra), - ).rejects.toThrow("Tool test-tool is disabled"); + await expect((tool.callback as any)(mockExtra)).rejects.toThrow( + "Tool test-tool is disabled", + ); }); it("tool validates input schema", async () => { @@ -460,9 +460,7 @@ describe("App <-> AppBridge integration", () => { } as any; // Valid output should work - await expect( - (tool.callback as any)(mockExtra), - ).resolves.toBeDefined(); + await expect((tool.callback as any)(mockExtra)).resolves.toBeDefined(); }); it("tool enable/disable/update/remove trigger sendToolListChanged", async () => { @@ -616,9 +614,7 @@ describe("App <-> AppBridge integration", () => { arguments: { name: "Alice" }, }); - expect(result.content).toEqual([ - { type: "text", text: "Hello, Alice!" }, - ]); + expect(result.content).toEqual([{ type: "text", text: "Hello, Alice!" }]); }); }); }); From b0e9cdb53cbb219c4803c6a2aed032e950ab9b6f Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 01:42:53 +0100 Subject: [PATCH 03/11] nits --- src/app-bridge.test.ts | 6 +----- src/app.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 4d08a820..d5e6550e 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -2,11 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { - EmptyResultSchema, - CallToolResultSchema, - ListToolsResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod/v4"; import { App } from "./app"; diff --git a/src/app.ts b/src/app.ts index 50507a54..8d3a3d09 100644 --- a/src/app.ts +++ b/src/app.ts @@ -43,7 +43,7 @@ import { McpUiToolResultNotificationSchema, } from "./types"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { safeParseAsync, ZodRawShape } from "zod/v4"; +import { safeParseAsync } from "zod/v4"; import { RegisteredTool, ToolCallback, From 81fb842fae76e8ee4485993b2c50e27567ff6d55 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 01:52:40 +0100 Subject: [PATCH 04/11] Update apps.mdx --- specification/draft/apps.mdx | 740 ++++++++++++++++++++++++++++++++++- 1 file changed, 736 insertions(+), 4 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 90f7a933..2a0b4e12 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -366,11 +366,23 @@ If the Host is a web page, it MUST wrap the Guest UI and communicate with it thr ### Standard MCP Messages -UI iframes can use the following subset of standard MCP protocol messages: +UI iframes can use the following subset of standard MCP protocol messages. + +Note that `tools/call` and `tools/list` flow **bidirectionally**: +- **App → Host → Server**: Apps call server tools (requires host `serverTools` capability) +- **Host → App**: Host calls app-registered tools (requires app `tools` capability) **Tools:** -- `tools/call` - Execute a tool on the MCP server +- `tools/call` - Execute a tool (bidirectional) + - **App → Host**: Call server tool via host proxy + - **Host → App**: Call app-registered tool +- `tools/list` - List available tools (bidirectional) + - **App → Host**: List server tools + - **Host → App**: List app-registered tools +- `notifications/tools/list_changed` - Notify when tool list changes (bidirectional) + - **Server → Host → App**: Server tools changed + - **App → Host**: App-registered tools changed **Resources:** @@ -532,6 +544,92 @@ Host SHOULD open the URL in the user's default browser or a new tab. Host SHOULD add the message to the conversation thread, preserving the specified role. +#### Requests (Host → App) + +When Apps declare the `tools` capability, the Host can send standard MCP tool requests to the App: + +`tools/call` - Execute an App-registered tool + +```typescript +// Request (Host → App) +{ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: string, // Name of app-registered tool to execute + arguments?: object // Tool arguments (validated against inputSchema) + } +} + +// Success Response (App → Host) +{ + jsonrpc: "2.0", + id: 1, + result: { + content: Array, // Result for model context + structuredContent?: object, // Optional structured data for UI + isError?: boolean, // True if tool execution failed + _meta?: object // Optional metadata + } +} + +// Error Response +{ + jsonrpc: "2.0", + id: 1, + error: { + code: number, + message: string + } +} +``` + +**App Behavior:** +- Apps MUST implement `oncalltool` handler if they declare `tools` capability +- Apps SHOULD validate tool names and arguments +- Apps MAY use `app.registerTool()` SDK helper for automatic validation +- Apps SHOULD return `isError: true` for tool execution failures + +`tools/list` - List App-registered tools + +```typescript +// Request (Host → App) +{ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: { + cursor?: string // Optional pagination cursor + } +} + +// Response (App → Host) +{ + jsonrpc: "2.0", + id: 2, + result: { + tools: Array, // List of available tools + nextCursor?: string // Pagination cursor if more tools exist + } +} +``` + +**Tool Structure:** +```typescript +interface Tool { + name: string; // Unique tool identifier + description?: string; // Human-readable description + inputSchema: object; // JSON Schema for arguments + annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint) +} +``` + +**App Behavior:** +- Apps MUST implement `onlisttools` handler if they declare `tools` capability +- Apps SHOULD return complete tool metadata including schemas +- Apps MAY filter tools based on context or permissions + #### Notifications (Host → UI) `ui/notifications/tool-input` - Host MUST send this notification with the complete tool arguments after the Guest UI's initialize request completes. @@ -903,6 +1001,443 @@ This pattern enables interactive, self-updating widgets. Note: The called tool may not appear in `tools/list` responses. MCP servers MAY expose private tools specifically designed for UI interaction that are not visible to the agent. UI implementations SHOULD attempt to call tools by name regardless of discoverability. The specification for Private Tools will be covered in a future SEP. +### App-Provided Tools + +Apps can register their own tools that hosts and agents can call, making apps **introspectable and accessible** to the model. This complements the existing capability where apps call server tools (via host proxy). + +#### Motivation: Semantic Introspection + +Without tool registration, apps are black boxes to the model: +- Model sees visual output (screenshots) but not semantic state +- Model cannot query app state without DOM parsing +- Model cannot discover what operations are available + +With tool registration, apps expose semantic interfaces: +- Model discovers available operations via `tools/list` +- Model queries app state via tools (e.g., `get_board_state`) +- Model executes actions via tools (e.g., `make_move`) +- Apps provide structured data instead of requiring HTML/CSS interpretation + +This is a different model from approaches where apps keep the model informed through side channels (e.g., OAI Apps SDK sending widget state changes to the model, MCP-UI adding tool call results to chat history). Instead, the agent actively queries app state and executes operations through tools. + +#### App Tool Registration + +Apps register tools using the SDK's `registerTool()` method: + +```typescript +import { App } from '@modelcontextprotocol/ext-apps'; +import { z } from 'zod'; + +const app = new App( + { name: "TicTacToe", version: "1.0.0" }, + { tools: { listChanged: true } } // Declare tool capability +); + +// Register a tool with schema validation +const moveTool = app.registerTool( + "tictactoe_move", + { + description: "Make a move in the tic-tac-toe game", + inputSchema: z.object({ + position: z.number().int().min(0).max(8), + player: z.enum(['X', 'O']) + }), + outputSchema: z.object({ + board: z.array(z.string()).length(9), + winner: z.enum(['X', 'O', 'draw', null]).nullable() + }), + annotations: { + readOnlyHint: false // This tool has side effects + } + }, + async (params) => { + // Validate and execute move + const newBoard = makeMove(params.position, params.player); + const winner = checkWinner(newBoard); + + return { + content: [{ + type: "text", + text: `Move made at position ${params.position}` + }], + structuredContent: { + board: newBoard, + winner + } + }; + } +); + +await app.connect(new PostMessageTransport(window.parent)); +``` + +**Registration Options:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique tool identifier | +| `description` | string | No | Human-readable description for agent | +| `inputSchema` | Zod schema or JSON Schema | No | Validates arguments | +| `outputSchema` | Zod schema | No | Validates return value | +| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) | +| `_meta` | object | No | Custom metadata | + +Apps can also implement tool handling manually without the SDK: + +```javascript +app.oncalltool = async (params, extra) => { + if (params.name === "tictactoe_move") { + // Manual validation + if (typeof params.arguments?.position !== 'number') { + throw new Error("Invalid position"); + } + + // Execute tool + const newBoard = makeMove(params.arguments.position, params.arguments.player); + + return { + content: [{ type: "text", text: "Move made" }], + structuredContent: { board: newBoard } + }; + } + + throw new Error(`Unknown tool: ${params.name}`); +}; + +app.onlisttools = async () => { + return { + tools: [ + { + name: "tictactoe_move", + description: "Make a move in the game", + inputSchema: { + type: "object", + properties: { + position: { type: "number", minimum: 0, maximum: 8 }, + player: { type: "string", enum: ["X", "O"] } + }, + required: ["position", "player"] + } + } + ] + }; +}; +``` + +#### Tool Lifecycle + +Registered tools support dynamic lifecycle management: + +**Enable/Disable:** + +```typescript +const tool = app.registerTool("my_tool", config, callback); + +// Disable tool (hide from tools/list) +tool.disable(); + +// Re-enable tool +tool.enable(); +``` + +When a tool is disabled/enabled, the app automatically sends `notifications/tools/list_changed` (if the app declared `listChanged: true` capability). + +**Update:** + +```typescript +// Update tool description or schema +tool.update({ + description: "New description", + inputSchema: newSchema +}); +``` + +Updates also trigger `notifications/tools/list_changed`. + +**Remove:** + +```typescript +// Permanently remove tool +tool.remove(); +``` + +#### Schema Validation + +The SDK provides automatic schema validation using Zod: + +**Input Validation:** + +```typescript +app.registerTool( + "search", + { + inputSchema: z.object({ + query: z.string().min(1).max(100), + limit: z.number().int().positive().default(10) + }) + }, + async (params) => { + // params.query is guaranteed to be a string (1-100 chars) + // params.limit is guaranteed to be a positive integer (default 10) + return performSearch(params.query, params.limit); + } +); +``` + +If the host sends invalid arguments, the tool automatically returns an error before the callback is invoked. + +**Output Validation:** + +```typescript +app.registerTool( + "get_status", + { + outputSchema: z.object({ + status: z.enum(['ready', 'busy', 'error']), + timestamp: z.string().datetime() + }) + }, + async () => { + return { + content: [{ type: "text", text: "Status retrieved" }], + structuredContent: { + status: 'ready', + timestamp: new Date().toISOString() + } + }; + } +); +``` + +If the callback returns data that doesn't match `outputSchema`, the tool returns an error. + +#### Complete Example: Introspectable Tic-Tac-Toe + +This example demonstrates how apps expose semantic interfaces through tools: + +```typescript +import { App } from '@modelcontextprotocol/ext-apps'; +import { z } from 'zod'; + +// Game state +let board: Array<'X' | 'O' | null> = Array(9).fill(null); +let currentPlayer: 'X' | 'O' = 'X'; +let moveHistory: number[] = []; + +const app = new App( + { name: "TicTacToe", version: "1.0.0" }, + { tools: { listChanged: true } } +); + +// Agent can query semantic state (no DOM parsing) +app.registerTool( + "get_board_state", + { + description: "Get current game state including board, current player, and winner", + outputSchema: z.object({ + board: z.array(z.enum(['X', 'O', null])).length(9), + currentPlayer: z.enum(['X', 'O']), + winner: z.enum(['X', 'O', 'draw', null]).nullable(), + moveHistory: z.array(z.number()) + }) + }, + async () => { + return { + content: [{ + type: "text", + text: `Board: ${board.map(c => c || '-').join('')}, Player: ${currentPlayer}` + }], + structuredContent: { + board, + currentPlayer, + winner: checkWinner(board), + moveHistory + } + }; + } +); + +// Agent can execute moves +app.registerTool( + "make_move", + { + description: "Place a piece at the specified position", + inputSchema: z.object({ + position: z.number().int().min(0).max(8) + }), + annotations: { readOnlyHint: false } + }, + async ({ position }) => { + if (board[position] !== null) { + return { + content: [{ type: "text", text: "Position already taken" }], + isError: true + }; + } + + board[position] = currentPlayer; + moveHistory.push(position); + const winner = checkWinner(board); + currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; + + return { + content: [{ + type: "text", + text: `Player ${board[position]} moved to position ${position}` + + (winner ? `. ${winner} wins!` : '') + }], + structuredContent: { + board, + currentPlayer, + winner, + moveHistory + } + }; + } +); + +// Agent can reset game +app.registerTool( + "reset_game", + { + description: "Reset the game board to initial state", + annotations: { readOnlyHint: false } + }, + async () => { + board = Array(9).fill(null); + currentPlayer = 'X'; + moveHistory = []; + + return { + content: [{ type: "text", text: "Game reset" }], + structuredContent: { board, currentPlayer, moveHistory } + }; + } +); + +await app.connect(new PostMessageTransport(window.parent)); + +function checkWinner(board: Array<'X' | 'O' | null>): 'X' | 'O' | 'draw' | null { + const lines = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows + [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns + [0, 4, 8], [2, 4, 6] // diagonals + ]; + + for (const [a, b, c] of lines) { + if (board[a] && board[a] === board[b] && board[a] === board[c]) { + return board[a]; + } + } + + return board.every(cell => cell !== null) ? 'draw' : null; +} +``` + +**Agent Interaction:** + +```typescript +// 1. Discover available operations +const { tools } = await bridge.sendListTools({}); +// → ["get_board_state", "make_move", "reset_game"] + +// 2. Query semantic state (not visual/DOM) +const state = await bridge.sendCallTool({ + name: "get_board_state", + arguments: {} +}); +// → { board: [null, null, null, ...], currentPlayer: 'X', winner: null } + +// 3. Execute actions based on semantic understanding +if (state.structuredContent.board[4] === null) { + await bridge.sendCallTool({ + name: "make_move", + arguments: { position: 4 } + }); +} + +// 4. Query updated state +const newState = await bridge.sendCallTool({ + name: "get_board_state", + arguments: {} +}); +// → { board: [null, null, null, null, 'X', null, ...], currentPlayer: 'O', ... } +``` + +The agent interacts with the app through semantic operations rather than visual interpretation. + +#### Tool Flow Directions + +**Existing Flow (unchanged): App → Host → Server** + +Apps call server tools (proxied by host): + +```typescript +// App calls server tool +const result = await app.callServerTool("get_weather", { location: "NYC" }); +``` + +Requires host `serverTools` capability. + +**New Flow: Host/Agent → App** + +Host/Agent calls app-registered tools: + +```typescript +// Host calls app tool +const result = await bridge.sendCallTool({ + name: "tictactoe_move", + arguments: { position: 4 } +}); +``` + +Requires app `tools` capability. + +**Key Distinction:** + +| Aspect | Server Tools | App Tools | +|--------|-------------|-----------| +| **Lifetime** | Persistent (server process) | Ephemeral (while app loaded) | +| **Source** | MCP Server | App JavaScript | +| **Trust** | Trusted | Sandboxed (untrusted) | +| **Discovery** | Server `tools/list` | App `tools/list` (when app declares capability) | +| **When Available** | Always | Only while app is loaded | + +#### Use Cases + +**Introspection:** Agent queries app state semantically without DOM parsing + +**Voice mode:** Agent drives app interactions programmatically based on voice commands + +**Accessibility:** Structured state and operations more accessible than visual rendering + +**Complex workflows:** Agent discovers available operations and coordinates multi-step interactions + +**Stateful apps:** Apps expose operations (move, reset, query) rather than pushing state updates via messages + +#### Security Implications + +App tools run in **sandboxed iframes** (untrusted). See Security Implications section for detailed mitigations. + +Key considerations: +- App tools could provide misleading descriptions +- Tool namespacing needed to avoid conflicts with server tools +- Resource limits (max tools, execution timeouts) +- Audit trail for app tool invocations +- User confirmation for tools with side effects + +#### Relation to WebMCP + +This feature is inspired by [WebMCP](https://github.com/webmachinelearning/webmcp) (W3C incubation), which proposes allowing web pages to register JavaScript functions as tools via `navigator.modelContext.registerTool()`. + +Key differences: +- **WebMCP**: General web pages, browser API, manifest-based discovery +- **This spec**: MCP Apps, standard MCP messages, capability-based negotiation + +Similar to WebMCP but without turning the App (embedded page) into an MCP server - apps register tools within the App/Host architecture. + +See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35) for discussion. + ### Client\<\>Server Capability Negotiation Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724). @@ -973,15 +1508,104 @@ if (hasUISupport) { - Tools MUST return meaningful content array even when UI is available - Servers MAY register different tool variants based on host capabilities +#### App (Guest UI) Capabilities + +Apps advertise their capabilities in the `ui/initialize` request to the host. When an app supports tool registration, it includes the `tools` capability: + +```json +{ + "method": "ui/initialize", + "params": { + "appInfo": { + "name": "TicTacToe", + "version": "1.0.0" + }, + "appCapabilities": { + "tools": { + "listChanged": true + } + } + } +} +``` + +The host responds with its own capabilities, including support for proxying server tools: + +```json +{ + "result": { + "hostInfo": { + "name": "claude-desktop", + "version": "1.0.0" + }, + "hostCapabilities": { + "serverTools": { + "listChanged": true + }, + "openLinks": {}, + "logging": {} + } + } +} +``` + +**App Capability: `tools`** + +When present, the app can register tools that the host and agent can call. + +- `listChanged` (boolean, optional): If `true`, the app will send `notifications/tools/list_changed` when tools are added, removed, or modified + +**Host Capability: `serverTools`** + +When present, the host can proxy calls from the app to MCP server tools. + +- `listChanged` (boolean, optional): If `true`, the host will send `notifications/tools/list_changed` when server tools change + +These capabilities are independent - an app can have one, both, or neither. + +**TypeScript Types:** + +```typescript +interface McpUiAppCapabilities { + tools?: { + listChanged?: boolean; + }; +} + +interface McpUiHostCapabilities { + serverTools?: { + listChanged?: boolean; + }; + openLinks?: {}; + logging?: {}; +} +``` + ### Extensibility -This specification defines the Minimum Viable Product (MVP) for MCP Apps. Future extensions may include: +This specification defines the Minimum Viable Product (MVP) for MCP Apps. + +**Included in MVP:** + +- **App-Provided Tools:** Apps can register tools via `app.registerTool()` that agents can call + - Bidirectional tool flow (Apps consume server tools AND provide app tools) + - Full lifecycle management (enable/disable/update/remove) + - Schema validation with Zod + - Tool list change notifications **Content Types (deferred from MVP):** - `externalUrl`: Embed external web applications (e.g., `text/uri-list`) -**Advanced Features (see Future Considerations):** +**Advanced Tool Features (future extensions):** + +- Tool namespacing standards and conventions +- Standardized permission model specifications +- Tool categories/tags for organization +- Cross-app tool composition +- Tool marketplace/discovery mechanisms + +**Other Advanced Features (see Future Considerations):** - Support multiple UI resources in a tool response - State persistence and restoration @@ -1045,6 +1669,37 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im - **Include external URLs in MVP:** This is one of the easiest content types for servers to adopt, as it's possible to embed regular apps. However, it was deferred due to concerns around model visibility, inability to screenshot content, and review process. - **Support multiple content types:** Deferred to maintain a lean MVP. +#### 4. App Tool Registration Support + +**Decision:** Enable Apps to register tools using standard MCP `tools/call` and `tools/list` messages, making tools flow bidirectionally between Apps and Hosts. + +**Rationale:** + +- **Semantic introspection:** Apps can expose their state and operations in structured, machine-readable format without requiring agents to parse DOM or interpret visual elements +- **Protocol reuse:** Reuses existing MCP tool infrastructure (`tools/call`, `tools/list`, `notifications/tools/list_changed`) instead of inventing new message types +- **WebMCP alignment:** Brings WebMCP's vision of "JavaScript functions as tools" to MCP Apps while staying MCP-native +- **Agent-driven interaction:** Enables agents to actively query app state and command app operations, rather than apps pushing state updates via custom messages +- **Bidirectional symmetry:** Apps act as both MCP clients (calling server tools) and MCP servers (providing app tools), creating clean architectural symmetry +- **Use case coverage:** Enables interactive games, stateful forms, complex workflows, and reusable widgets + +**Alternatives considered:** + +- **Custom app-action API:** Rejected because it would duplicate MCP's existing tool infrastructure and create parallel protocol semantics. Using standard `tools/call` means automatic compatibility with future MCP features and better ecosystem integration. +- **Server-side proxy tools:** Apps could expose operations by having the server register proxy tools that communicate back to the app. Rejected because it doesn't leverage the app's JavaScript execution environment, adds unnecessary round-trips, and couples app functionality to server implementation. +- **Resources instead of tools:** Apps could expose state via `resources/read` rather than tools. Rejected because resources have wrong semantics (passive data retrieval vs. active operations), don't support parameters well, and don't convey operational intent. + +**Security implications:** + +Apps are forward-deployed emanations of server tools, running in the client context. Hosts should consider how to handle tool call approval: + +- Per-app-instance approval (confirm each time a specific app instance calls a tool) +- Per-server approval (approve all apps from a trusted server) +- Per-tool approval (approve based on tool semantics and annotations) +- Clear attribution showing which app instance is calling a tool +- Audit trails for app tool calls + +See [Security Implications: App-Provided Tools Security](#5-app-provided-tools-security) for detailed considerations. + ### Backward Compatibility The proposal builds on the existing core protocol. There are no incompatibilities. @@ -1126,6 +1781,83 @@ const cspValue = ` - Host SHOULD warn users when UI requires external domain access - Host MAY implement global domain allowlists/blocklists +#### 5. App-Provided Tools Security + +Apps can register their own tools that agents can call. Apps are forward-deployed emanations of server tools, running in the client context. Hosts need to decide how to handle approval for app tool calls. + +**Approval Considerations:** + +App-provided tools introduce additional approval considerations: + +- **Tool description accuracy:** Apps may describe tools in ways that don't fully capture side effects +- **Namespace conflicts:** Apps could register tools with names conflicting with server tools +- **Resource consumption:** Apps could register many tools or implement slow callbacks +- **Data validation:** Tool results should match declared schemas +- **Semantic clarity:** Tool operations should be clear from their descriptions + +**Approval Granularity:** + +Hosts have discretion in how they handle app tool call approval: + +1. **Per-app-instance approval:** Confirm each time a specific app instance's tool is called +2. **Per-server approval:** Trust all apps from servers the user has approved +3. **Per-tool approval:** Approve based on tool annotations (e.g., `readOnlyHint`) +4. **Hybrid approaches:** Combine strategies (e.g., auto-approve read-only tools from trusted servers) + +**Host Protections:** + +Hosts SHOULD implement the following protections for app-provided tools: + +1. **Clear Attribution:** + - Display tool source in agent's tool list (e.g., "Tool from TicTacToe App") + - Visually distinguish app tools from server tools in UI + - Show app name and version in tool call confirmations + +2. **User Confirmation:** + - Require explicit user approval for tools with `readOnlyHint: false` + - Consider auto-approving tools with `readOnlyHint: true` after review + - Implement per-app permission settings (always allow, always deny, ask) + +3. **Namespace Management:** + - Recommend or enforce tool name prefixes (e.g., `app:move`, `tictactoe:move`) + - Prevent apps from registering tool names that conflict with server tools + - Document namespace conventions for app developers + +4. **Resource Limits:** + - Limit maximum number of tools per app (recommended: 50) + - Enforce execution timeouts for tool callbacks (recommended: 30 seconds) + - Limit tool result sizes (recommended: 10 MB) + - Throttle `tools/list_changed` notifications to prevent spam + +5. **Audit Trail:** + - Log all app tool registrations with timestamps + - Log all app tool calls with arguments and results + - Provide audit interface for users to review app tool activity + +6. **Result Validation:** + - Validate tool results match declared schemas + - Sanitize result content before displaying to user or agent + - Reject results that appear malicious (e.g., phishing content) + +**Permission Model:** + +Hosts MAY implement different permission levels based on tool annotations: + +| Annotation | Recommended Permission | Example | +|---------------------|------------------------|-------------------| +| `readOnlyHint: true`| Auto-approve (with caution) | `get_board_state()` | +| `readOnlyHint: false` | User confirmation required | `make_move()` | +| No annotation | User confirmation required (safe default) | Any tool | + +**App Tool Lifecycle:** + +App tools MUST be tied to the app's lifecycle: + +- Tools become available only after app sends `notifications/tools/list_changed` +- Tools automatically disappear when app iframe is torn down +- Hosts MUST NOT persist app tool registrations across sessions +- Calling a tool from a closed app MUST return an error + ### Other risks - **Social engineering:** UI can still display misleading content. Hosts should clearly indicate sandboxed UI boundaries. From 6af02a936293656beb9260eb6494cbb5f670745d Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 16:55:43 +0100 Subject: [PATCH 05/11] feat: Add automatic request handlers for app tool registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement automatic `oncalltool` and `onlisttools` handlers that are initialized when apps register tools. This removes the need for manual handler setup and ensures tools work seamlessly out of the box. - Add automatic `oncalltool` handler that routes calls to registered tools - Add automatic `onlisttools` handler that returns full Tool objects with JSON schemas - Convert Zod schemas to MCP-compliant JSON Schema using `zod-to-json-schema` - Add 27 comprehensive tests covering automatic handlers and tool lifecycle - Test coverage includes error handling, schema validation, and multi-app isolation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package-lock.json | 26 +- package.json | 3 +- src/app-bridge.test.ts | 528 +++++++++++++++++++++++++++++++++++++++++ src/app.ts | 22 ++ 4 files changed, 577 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f17d86e9..32a404db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,11 @@ "bun": "^1.3.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^3.25" + "zod": "^3.25", + "zod-to-json-schema": "^3.25.0" }, "devDependencies": { + "@types/bun": "^1.3.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "concurrently": "^9.2.1", @@ -590,6 +592,16 @@ "@types/node": "*" } }, + "node_modules/@types/bun": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.3.tgz", + "integrity": "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.3" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "dev": true, @@ -1034,6 +1046,16 @@ "@oven/bun-windows-x64-baseline": "1.3.3" } }, + "node_modules/bun-types": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz", + "integrity": "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/bytes": { "version": "3.1.2", "license": "MIT", @@ -2942,6 +2964,8 @@ }, "node_modules/zod-to-json-schema": { "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/package.json b/package.json index c0a4986e..08d965fe 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "bun": "^1.3.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^3.25" + "zod": "^3.25", + "zod-to-json-schema": "^3.25.0" } } diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index d5e6550e..4e4498ec 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -298,6 +298,8 @@ describe("App <-> AppBridge integration", () => { describe("App tool registration", () => { beforeEach(async () => { + // App needs tool capabilities to register tools + app = new App(testAppInfo, { tools: {} }, { autoResize: false }); await bridge.connect(bridgeTransport); }); @@ -613,4 +615,530 @@ describe("App <-> AppBridge integration", () => { expect(result.content).toEqual([{ type: "text", text: "Hello, Alice!" }]); }); }); + + describe("Automatic request handlers", () => { + beforeEach(async () => { + await bridge.connect(bridgeTransport); + }); + + describe("oncalltool automatic handler", () => { + it("automatically calls registered tool without manual oncalltool setup", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + // Register a tool + app.registerTool( + "greet", + { + description: "Greets user", + inputSchema: z.object({ name: z.string() }) as any, + }, + async (args: any) => ({ + content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], + }), + ); + + await app.connect(appTransport); + + // Call the tool through bridge - should work automatically + const result = await bridge.sendCallTool({ + name: "greet", + arguments: { name: "Bob" }, + }); + + expect(result.content).toEqual([{ type: "text", text: "Hello, Bob!" }]); + }); + + it("throws error when calling non-existent tool", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + // Register a tool to initialize handlers + app.registerTool("existing-tool", {}, async (_args: any) => ({ + content: [], + })); + + await app.connect(appTransport); + + // Try to call a tool that doesn't exist + await expect( + bridge.sendCallTool({ + name: "nonexistent", + arguments: {}, + }), + ).rejects.toThrow("Tool nonexistent not found"); + }); + + it("handles multiple registered tools correctly", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + // Register multiple tools + app.registerTool( + "add", + { + description: "Add two numbers", + inputSchema: z.object({ a: z.number(), b: z.number() }) as any, + }, + async (args: any) => ({ + content: [ + { + type: "text" as const, + text: `Result: ${args.a + args.b}`, + }, + ], + structuredContent: { result: args.a + args.b }, + }), + ); + + app.registerTool( + "multiply", + { + description: "Multiply two numbers", + inputSchema: z.object({ a: z.number(), b: z.number() }) as any, + }, + async (args: any) => ({ + content: [ + { + type: "text" as const, + text: `Result: ${args.a * args.b}`, + }, + ], + structuredContent: { result: args.a * args.b }, + }), + ); + + await app.connect(appTransport); + + // Call first tool + const addResult = await bridge.sendCallTool({ + name: "add", + arguments: { a: 5, b: 3 }, + }); + expect(addResult.content).toEqual([ + { type: "text", text: "Result: 8" }, + ]); + + // Call second tool + const multiplyResult = await bridge.sendCallTool({ + name: "multiply", + arguments: { a: 5, b: 3 }, + }); + expect(multiplyResult.content).toEqual([ + { type: "text", text: "Result: 15" }, + ]); + }); + + it("respects tool enable/disable state", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + const tool = app.registerTool( + "test-tool", + { + description: "Test tool", + }, + async (_args: any) => ({ + content: [{ type: "text" as const, text: "Success" }], + }), + ); + + await app.connect(appTransport); + + // Should work when enabled + await expect( + bridge.sendCallTool({ name: "test-tool", arguments: {} }), + ).resolves.toBeDefined(); + + // Disable tool + tool.disable(); + + // Should throw when disabled + await expect( + bridge.sendCallTool({ name: "test-tool", arguments: {} }), + ).rejects.toThrow("Tool test-tool is disabled"); + }); + + it("validates input schema through automatic handler", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + app.registerTool( + "strict-tool", + { + description: "Requires specific input", + inputSchema: z.object({ + required: z.string(), + optional: z.number().optional(), + }) as any, + }, + async (args: any) => ({ + content: [ + { type: "text" as const, text: `Got: ${args.required}` }, + ], + }), + ); + + await app.connect(appTransport); + + // Valid input should work + await expect( + bridge.sendCallTool({ + name: "strict-tool", + arguments: { required: "hello" }, + }), + ).resolves.toBeDefined(); + + // Invalid input should fail + await expect( + bridge.sendCallTool({ + name: "strict-tool", + arguments: { wrong: "field" }, + }), + ).rejects.toThrow("Invalid input for tool strict-tool"); + }); + + it("validates output schema through automatic handler", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + app.registerTool( + "validated-output", + { + description: "Has output validation", + outputSchema: z.object({ + status: z.enum(["success", "error"]), + }) as any, + }, + async (_args: any) => ({ + content: [{ type: "text" as const, text: "Done" }], + structuredContent: { status: "success" }, + }), + ); + + await app.connect(appTransport); + + // Valid output should work + const result = await bridge.sendCallTool({ + name: "validated-output", + arguments: {}, + }); + expect(result).toBeDefined(); + }); + + it("works after tool is removed and re-registered", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + const tool = app.registerTool( + "dynamic-tool", + {}, + async (_args: any) => ({ + content: [{ type: "text" as const, text: "Version 1" }], + }), + ); + + await app.connect(appTransport); + + // First version + let result = await bridge.sendCallTool({ + name: "dynamic-tool", + arguments: {}, + }); + expect(result.content).toEqual([{ type: "text", text: "Version 1" }]); + + // Remove tool + tool.remove(); + + // Should fail after removal + await expect( + bridge.sendCallTool({ name: "dynamic-tool", arguments: {} }), + ).rejects.toThrow("Tool dynamic-tool not found"); + + // Re-register with different behavior + app.registerTool( + "dynamic-tool", + {}, + async (_args: any) => ({ + content: [{ type: "text" as const, text: "Version 2" }], + }), + ); + + // Should work with new version + result = await bridge.sendCallTool({ + name: "dynamic-tool", + arguments: {}, + }); + expect(result.content).toEqual([{ type: "text", text: "Version 2" }]); + }); + }); + + describe("onlisttools automatic handler", () => { + it("automatically returns list of registered tool names", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + // Register some tools + app.registerTool("tool1", {}, async (_args: any) => ({ + content: [], + })); + app.registerTool("tool2", {}, async (_args: any) => ({ + content: [], + })); + app.registerTool("tool3", {}, async (_args: any) => ({ + content: [], + })); + + await app.connect(appTransport); + + const result = await bridge.sendListTools({}); + + expect(result.tools).toHaveLength(3); + expect(result.tools.map((t) => t.name)).toContain("tool1"); + expect(result.tools.map((t) => t.name)).toContain("tool2"); + expect(result.tools.map((t) => t.name)).toContain("tool3"); + }); + + it("returns empty list when no tools registered", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + // Register a tool to ensure handlers are initialized + const dummyTool = app.registerTool("dummy", {}, async () => ({ + content: [], + })); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + // Remove the tool after connecting + dummyTool.remove(); + + const result = await bridge.sendListTools({}); + + expect(result.tools).toEqual([]); + }); + + it("updates list when tools are added", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + // Register then remove a tool to initialize handlers + const dummy = app.registerTool("init", {}, async () => ({ content: [] })); + dummy.remove(); + + // Initially no tools + let result = await bridge.sendListTools({}); + expect(result.tools).toEqual([]); + + // Add a tool + app.registerTool("new-tool", {}, async (_args: any) => ({ + content: [], + })); + + // Should now include the new tool + result = await bridge.sendListTools({}); + expect(result.tools.map((t) => t.name)).toEqual(["new-tool"]); + + // Add another tool + app.registerTool("another-tool", {}, async (_args: any) => ({ + content: [], + })); + + // Should now include both tools + result = await bridge.sendListTools({}); + expect(result.tools).toHaveLength(2); + expect(result.tools.map((t) => t.name)).toContain("new-tool"); + expect(result.tools.map((t) => t.name)).toContain("another-tool"); + }); + + it("updates list when tools are removed", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + const tool1 = app.registerTool("tool1", {}, async (_args: any) => ({ + content: [], + })); + const tool2 = app.registerTool("tool2", {}, async (_args: any) => ({ + content: [], + })); + const tool3 = app.registerTool("tool3", {}, async (_args: any) => ({ + content: [], + })); + + await app.connect(appTransport); + + // Initially all three tools + let result = await bridge.sendListTools({}); + expect(result.tools).toHaveLength(3); + + // Remove one tool + tool2.remove(); + + // Should now have two tools + result = await bridge.sendListTools({}); + expect(result.tools).toHaveLength(2); + expect(result.tools.map((t) => t.name)).toContain("tool1"); + expect(result.tools.map((t) => t.name)).toContain("tool3"); + expect(result.tools.map((t) => t.name)).not.toContain("tool2"); + + // Remove another tool + tool1.remove(); + + // Should now have one tool + result = await bridge.sendListTools({}); + expect(result.tools.map((t) => t.name)).toEqual(["tool3"]); + }); + + it("includes both enabled and disabled tools in list", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + const tool1 = app.registerTool("enabled-tool", {}, async (_args: any) => ({ + content: [], + })); + const tool2 = app.registerTool("disabled-tool", {}, async (_args: any) => ({ + content: [], + })); + + await app.connect(appTransport); + + // Disable one tool after connecting + tool2.disable(); + + const result = await bridge.sendListTools({}); + + // Both tools should be in the list + expect(result.tools).toHaveLength(2); + expect(result.tools.map((t) => t.name)).toContain("enabled-tool"); + expect(result.tools.map((t) => t.name)).toContain("disabled-tool"); + }); + }); + + describe("Integration: automatic handlers with tool lifecycle", () => { + it("handles complete tool lifecycle: register -> call -> update -> call -> remove", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + await app.connect(appTransport); + + // Register tool + const tool = app.registerTool( + "counter", + { + description: "A counter tool", + }, + async (_args: any) => ({ + content: [{ type: "text" as const, text: "Count: 1" }], + structuredContent: { count: 1 }, + }), + ); + + // List should include the tool + let listResult = await bridge.sendListTools({}); + expect(listResult.tools.map((t) => t.name)).toContain("counter"); + + // Call the tool + let callResult = await bridge.sendCallTool({ + name: "counter", + arguments: {}, + }); + expect(callResult.content).toEqual([{ type: "text", text: "Count: 1" }]); + + // Update tool description + tool.update({ description: "An updated counter tool" }); + + // Should still be callable + callResult = await bridge.sendCallTool({ + name: "counter", + arguments: {}, + }); + expect(callResult).toBeDefined(); + + // Remove tool + tool.remove(); + + // Should no longer be in list + listResult = await bridge.sendListTools({}); + expect(listResult.tools.map((t) => t.name)).not.toContain("counter"); + + // Should no longer be callable + await expect( + bridge.sendCallTool({ name: "counter", arguments: {} }), + ).rejects.toThrow("Tool counter not found"); + }); + + it("multiple apps can have separate tool registries", async () => { + const appCapabilities = { tools: { listChanged: true } }; + + // Create two separate apps + const app1 = new App( + { name: "App1", version: "1.0.0" }, + appCapabilities, + { autoResize: false }, + ); + const app2 = new App( + { name: "App2", version: "1.0.0" }, + appCapabilities, + { autoResize: false }, + ); + + // Create separate transports for each app + const [app1Transport, bridge1Transport] = + InMemoryTransport.createLinkedPair(); + const [app2Transport, bridge2Transport] = + InMemoryTransport.createLinkedPair(); + + const bridge1 = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + const bridge2 = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + + // Register different tools in each app + app1.registerTool("app1-tool", {}, async (_args: any) => ({ + content: [{ type: "text" as const, text: "From App1" }], + })); + + app2.registerTool("app2-tool", {}, async (_args: any) => ({ + content: [{ type: "text" as const, text: "From App2" }], + })); + + await bridge1.connect(bridge1Transport); + await bridge2.connect(bridge2Transport); + await app1.connect(app1Transport); + await app2.connect(app2Transport); + + // Each app should only see its own tools + const list1 = await bridge1.sendListTools({}); + expect(list1.tools.map((t) => t.name)).toEqual(["app1-tool"]); + + const list2 = await bridge2.sendListTools({}); + expect(list2.tools.map((t) => t.name)).toEqual(["app2-tool"]); + + // Each app should only be able to call its own tools + await expect( + bridge1.sendCallTool({ name: "app1-tool", arguments: {} }), + ).resolves.toBeDefined(); + + await expect( + bridge1.sendCallTool({ name: "app2-tool", arguments: {} }), + ).rejects.toThrow("Tool app2-tool not found"); + + // Clean up + await app1Transport.close(); + await bridge1Transport.close(); + await app2Transport.close(); + await bridge2Transport.close(); + }); + }); + }); }); diff --git a/src/app.ts b/src/app.ts index 8d3a3d09..e674773d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -49,6 +49,7 @@ import { ToolCallback, } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ZodSchema } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; export { PostMessageTransport } from "./message-transport.js"; export * from "./types"; @@ -315,6 +316,27 @@ export class App extends Protocol { return; } this._toolHandlersInitialized = true; + + this.oncalltool = async (params, extra) => { + const tool = this._registeredTools[params.name]; + if (!tool) { + throw new Error(`Tool ${params.name} not found`); + } + return tool.callback(params.arguments as any, extra); + }; + this.onlisttools = async () => { + const tools = Object.entries(this._registeredTools).map( + ([name, tool]) => ({ + name, + description: tool.description, + inputSchema: + tool.inputSchema && "shape" in tool.inputSchema + ? zodToJsonSchema(tool.inputSchema as any) + : tool.inputSchema || { type: "object" as const, properties: {} }, + }), + ); + return { tools }; + }; } async sendToolListChanged( From 09256c92cffd0e590ada6d455e03a81fb0b202c1 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:11:16 +0100 Subject: [PATCH 06/11] npm run prettier:fix --- src/app-bridge.test.ts | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 4e4498ec..4fdfdd0f 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -773,9 +773,7 @@ describe("App <-> AppBridge integration", () => { }) as any, }, async (args: any) => ({ - content: [ - { type: "text" as const, text: `Got: ${args.required}` }, - ], + content: [{ type: "text" as const, text: `Got: ${args.required}` }], }), ); @@ -856,13 +854,9 @@ describe("App <-> AppBridge integration", () => { ).rejects.toThrow("Tool dynamic-tool not found"); // Re-register with different behavior - app.registerTool( - "dynamic-tool", - {}, - async (_args: any) => ({ - content: [{ type: "text" as const, text: "Version 2" }], - }), - ); + app.registerTool("dynamic-tool", {}, async (_args: any) => ({ + content: [{ type: "text" as const, text: "Version 2" }], + })); // Should work with new version result = await bridge.sendCallTool({ @@ -927,7 +921,9 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // Register then remove a tool to initialize handlers - const dummy = app.registerTool("init", {}, async () => ({ content: [] })); + const dummy = app.registerTool("init", {}, async () => ({ + content: [], + })); dummy.remove(); // Initially no tools @@ -997,12 +993,20 @@ describe("App <-> AppBridge integration", () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); - const tool1 = app.registerTool("enabled-tool", {}, async (_args: any) => ({ - content: [], - })); - const tool2 = app.registerTool("disabled-tool", {}, async (_args: any) => ({ - content: [], - })); + const tool1 = app.registerTool( + "enabled-tool", + {}, + async (_args: any) => ({ + content: [], + }), + ); + const tool2 = app.registerTool( + "disabled-tool", + {}, + async (_args: any) => ({ + content: [], + }), + ); await app.connect(appTransport); @@ -1046,7 +1050,9 @@ describe("App <-> AppBridge integration", () => { name: "counter", arguments: {}, }); - expect(callResult.content).toEqual([{ type: "text", text: "Count: 1" }]); + expect(callResult.content).toEqual([ + { type: "text", text: "Count: 1" }, + ]); // Update tool description tool.update({ description: "An updated counter tool" }); From 8b51250a47987ec57e2107898881b80d4a8fc9ce Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:22:08 +0100 Subject: [PATCH 07/11] type updates --- src/app.ts | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/app.ts b/src/app.ts index e674773d..eabae928 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,11 +13,13 @@ import { Implementation, ListToolsRequest, ListToolsRequestSchema, + ListToolsResult, LoggingMessageNotification, Notification, PingRequestSchema, Request, Result, + Tool, ToolAnnotations, ToolListChangedNotification, } from "@modelcontextprotocol/sdk/types.js"; @@ -48,7 +50,7 @@ import { RegisteredTool, ToolCallback, } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { ZodSchema } from "zod"; +import { z, ZodSchema } from "zod/v4"; import { zodToJsonSchema } from "zod-to-json-schema"; export { PostMessageTransport } from "./message-transport.js"; @@ -324,17 +326,24 @@ export class App extends Protocol { } return tool.callback(params.arguments as any, extra); }; - this.onlisttools = async () => { - const tools = Object.entries(this._registeredTools).map( - ([name, tool]) => ({ - name, - description: tool.description, - inputSchema: - tool.inputSchema && "shape" in tool.inputSchema - ? zodToJsonSchema(tool.inputSchema as any) - : tool.inputSchema || { type: "object" as const, properties: {} }, - }), - ); + this.onlisttools = async (_params, _extra) => { + const tools: Tool[] = Object.entries(this._registeredTools) + .filter(([_, tool]) => tool.enabled) + .map( + ([name, tool]) => + { + name, + description: tool.description, + inputSchema: tool.inputSchema + ? z.toJSONSchema(tool.inputSchema as ZodSchema) + : undefined, + outputSchema: tool.outputSchema + ? z.toJSONSchema(tool.outputSchema as ZodSchema) + : undefined, + annotations: tool.annotations, + _meta: tool._meta, + }, + ); return { tools }; }; } @@ -637,7 +646,7 @@ export class App extends Protocol { callback: ( params: ListToolsRequest["params"], extra: RequestHandlerExtra, - ) => Promise, + ) => Promise, ) { this.setRequestHandler(ListToolsRequestSchema, (request, extra) => callback(request.params, extra), From 6fd7513a35ce8a177f68b5a982e766034fb0497b Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:24:24 +0100 Subject: [PATCH 08/11] fix: Ensure tools/list returns valid JSON Schema for all tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always return inputSchema as object (never undefined) - Keep filter for enabled tools only in list - Update test to match behavior (only enabled tools in list) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app-bridge.test.ts | 8 ++++---- src/app.ts | 31 ++++++++++++++++--------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 4fdfdd0f..17c86021 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -989,7 +989,7 @@ describe("App <-> AppBridge integration", () => { expect(result.tools.map((t) => t.name)).toEqual(["tool3"]); }); - it("includes both enabled and disabled tools in list", async () => { + it("only includes enabled tools in list", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); @@ -1015,10 +1015,10 @@ describe("App <-> AppBridge integration", () => { const result = await bridge.sendListTools({}); - // Both tools should be in the list - expect(result.tools).toHaveLength(2); + // Only enabled tool should be in the list + expect(result.tools).toHaveLength(1); expect(result.tools.map((t) => t.name)).toContain("enabled-tool"); - expect(result.tools.map((t) => t.name)).toContain("disabled-tool"); + expect(result.tools.map((t) => t.name)).not.toContain("disabled-tool"); }); }); diff --git a/src/app.ts b/src/app.ts index eabae928..57c4cac6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -329,21 +329,22 @@ export class App extends Protocol { this.onlisttools = async (_params, _extra) => { const tools: Tool[] = Object.entries(this._registeredTools) .filter(([_, tool]) => tool.enabled) - .map( - ([name, tool]) => - { - name, - description: tool.description, - inputSchema: tool.inputSchema - ? z.toJSONSchema(tool.inputSchema as ZodSchema) - : undefined, - outputSchema: tool.outputSchema - ? z.toJSONSchema(tool.outputSchema as ZodSchema) - : undefined, - annotations: tool.annotations, - _meta: tool._meta, - }, - ); + .map(([name, tool]) => { + const result: Tool = { + name, + description: tool.description, + inputSchema: tool.inputSchema + ? z.toJSONSchema(tool.inputSchema as ZodSchema) + : { type: "object" as const, properties: {} }, + }; + if (tool.annotations) { + result.annotations = tool.annotations; + } + if (tool._meta) { + result._meta = tool._meta; + } + return result; + }); return { tools }; }; } From 9c996e86bc1f4cf0d909a02ec9deed0e297d83a9 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:37:59 +0100 Subject: [PATCH 09/11] type updates --- src/app.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 57c4cac6..d9918f48 100644 --- a/src/app.ts +++ b/src/app.ts @@ -333,9 +333,18 @@ export class App extends Protocol { const result: Tool = { name, description: tool.description, - inputSchema: tool.inputSchema + inputSchema: (tool.inputSchema ? z.toJSONSchema(tool.inputSchema as ZodSchema) - : { type: "object" as const, properties: {} }, + : { + type: "object" as const, + properties: {}, + }) as Tool["inputSchema"], + outputSchema: (tool.outputSchema + ? z.toJSONSchema(tool.outputSchema as ZodSchema) + : { + type: "object" as const, + properties: {}, + }) as Tool["outputSchema"], }; if (tool.annotations) { result.annotations = tool.annotations; From 3e329f3134c352190a19432148e63c5d90c5d023 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:52:27 +0100 Subject: [PATCH 10/11] rm zod-to-json-schema --- package-lock.json | 3 +-- package.json | 3 +-- src/app.ts | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32a404db..3773a990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,7 @@ "bun": "^1.3.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^3.25", - "zod-to-json-schema": "^3.25.0" + "zod": "^3.25" }, "devDependencies": { "@types/bun": "^1.3.2", diff --git a/package.json b/package.json index 08d965fe..c0a4986e 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "bun": "^1.3.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^3.25", - "zod-to-json-schema": "^3.25.0" + "zod": "^3.25" } } diff --git a/src/app.ts b/src/app.ts index d9918f48..311952cd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -51,7 +51,6 @@ import { ToolCallback, } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z, ZodSchema } from "zod/v4"; -import { zodToJsonSchema } from "zod-to-json-schema"; export { PostMessageTransport } from "./message-transport.js"; export * from "./types"; From 7be33c98812e76ec958ad5eeafa2d4cf1e07e508 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 18:16:45 +0100 Subject: [PATCH 11/11] fix npm bug where optional nested deps not fetched --- package-lock.json | 60 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 7 ++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 3773a990..ba87606a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,13 @@ "prettier": "^3.6.2", "typedoc": "^0.28.14", "typescript": "^5.9.3" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" } }, "examples/simple-host": { @@ -482,12 +489,50 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.53.3", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -506,6 +551,19 @@ "linux" ] }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@shikijs/engine-oniguruma": { "version": "3.15.0", "dev": true, diff --git a/package.json b/package.json index c0a4986e..ffc4d493 100644 --- a/package.json +++ b/package.json @@ -58,5 +58,12 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "zod": "^3.25" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" } }