From 331d1fd213edcdb9dc5ebb0e2b3190e4ca3d8f4d Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 00:50:52 +0100 Subject: [PATCH 01/17] 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. - 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 - Add `sendCallTool()` method for calling tools on the app - Add `sendListTools()` method for listing available app tools - Fix: Use correct ListToolsResultSchema (was ListToolsRequestSchema) - 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 | 852 +++++++++++++++++++++++++++++++++++++++++ src/app-bridge.ts | 19 + src/app.ts | 123 +++++- 3 files changed, 992 insertions(+), 2 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 66d5f830..114d41af 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -12,6 +12,7 @@ import { ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod/v4"; import { App } from "./app"; import { @@ -628,6 +629,857 @@ 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); + }); + + 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!" }]); + }); + }); + + 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("only includes enabled 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({}); + + // 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)).not.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(); + }); + }); + }); describe("AppBridge without MCP client (manual handlers)", () => { let app: App; let bridge: AppBridge; diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 2e969585..de6a4d75 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -19,6 +19,9 @@ import { ListResourceTemplatesRequestSchema, ListResourceTemplatesResult, ListResourceTemplatesResultSchema, + ListToolsRequest, + ListToolsRequestSchema, + ListToolsResultSchema, LoggingMessageNotification, LoggingMessageNotificationSchema, PingRequest, @@ -1333,6 +1336,22 @@ export class AppBridge extends Protocol< /** @deprecated Use {@link teardownResource} instead */ sendResourceTeardown: AppBridge["teardownResource"] = this.teardownResource; + 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 optionally set up message forwarding. * diff --git a/src/app.ts b/src/app.ts index e24913e3..c939b0bd 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"; @@ -15,6 +16,10 @@ import { ListToolsRequestSchema, LoggingMessageNotification, PingRequestSchema, + Request, + Result, + ToolAnnotations, + ToolListChangedNotification, } from "@modelcontextprotocol/sdk/types.js"; import { AppNotification, AppRequest, AppResult } from "./types"; import { PostMessageTransport } from "./message-transport"; @@ -49,6 +54,12 @@ import { McpUiRequestDisplayModeResultSchema, } 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"; export * from "./types"; @@ -207,6 +218,7 @@ export class App extends Protocol { private _hostCapabilities?: McpUiHostCapabilities; private _hostInfo?: Implementation; private _hostContext?: McpUiHostContext; + private _registeredTools: { [name: string]: RegisteredTool } = {}; /** * Create a new MCP App instance. @@ -241,6 +253,111 @@ export class App extends Protocol { this.onhostcontextchanged = () => {}; } + 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. * @@ -638,7 +755,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: {} } } + * ] * }; * }; * ``` @@ -650,7 +769,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 6b5a5c1f4aa4ee38fd4906cd765e9fbda623c4a1 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 01:42:53 +0100 Subject: [PATCH 02/17] nits --- src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index c939b0bd..91a5d01e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -54,7 +54,7 @@ import { McpUiRequestDisplayModeResultSchema, } 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 a01523580eece7fe8b9c7ae0d251734529c0fadc Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 01:52:40 +0100 Subject: [PATCH 03/17] 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 59eaa020..230ea98f 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -478,11 +478,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:** @@ -1022,6 +1034,92 @@ Host behavior: - If multiple updates are received before the next user message, Host SHOULD only send the last update to the model - MAY display context updates to the user +#### 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. @@ -1410,6 +1508,443 @@ This pattern enables interactive, self-updating widgets. Note: Tools with `visibility: ["app"]` are hidden from the agent but remain callable by apps via `tools/call`. This enables UI-only interactions (refresh buttons, form submissions) without exposing implementation details to the model. See the Visibility section under Resource Discovery for details. +### 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). @@ -1480,15 +2015,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 @@ -1588,6 +2212,37 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im - **Boolean `private` flag:** Simpler but less flexible; doesn't express model-only tools. - **Flat `ui/visibility` key:** Rejected in favor of nested structure for consistency with future `_meta.ui` fields. +#### 6. 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. @@ -1678,6 +2333,83 @@ const allowAttribute = allowList.join(' '); - 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 afdd973a353821c2c8edd2313bfe662ba1a72288 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 16:55:43 +0100 Subject: [PATCH 04/17] 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 --- src/app-bridge.test.ts | 1 + src/app.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 114d41af..e4473d18 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -1480,6 +1480,7 @@ describe("App <-> AppBridge integration", () => { }); }); }); + describe("AppBridge without MCP client (manual handlers)", () => { let app: App; let bridge: AppBridge; diff --git a/src/app.ts b/src/app.ts index 91a5d01e..fb1e2136 100644 --- a/src/app.ts +++ b/src/app.ts @@ -60,6 +60,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"; export * from "./types"; @@ -347,6 +348,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 5e40620f867b6e17b01af7434638f3000120bfaa Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:22:08 +0100 Subject: [PATCH 05/17] type updates --- src/app.ts | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/app.ts b/src/app.ts index fb1e2136..591f84dc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,10 +14,12 @@ import { Implementation, ListToolsRequest, ListToolsRequestSchema, + ListToolsResult, LoggingMessageNotification, PingRequestSchema, Request, Result, + Tool, ToolAnnotations, ToolListChangedNotification, } from "@modelcontextprotocol/sdk/types.js"; @@ -59,7 +61,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"; @@ -356,17 +358,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 }; }; } @@ -791,7 +800,7 @@ export class App extends Protocol { callback: ( params: ListToolsRequest["params"], extra: RequestHandlerExtra, - ) => Promise, + ) => Promise, ) { this.setRequestHandler(ListToolsRequestSchema, (request, extra) => callback(request.params, extra), From 07816746e7b33712277f1564306eaca47b09f218 Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:24:24 +0100 Subject: [PATCH 06/17] 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.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/app.ts b/src/app.ts index 591f84dc..01ba93a1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -361,21 +361,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 81a9b431148627466a01822c85321ba54547b68e Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:37:59 +0100 Subject: [PATCH 07/17] type updates --- src/app.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 01ba93a1..397aa314 100644 --- a/src/app.ts +++ b/src/app.ts @@ -365,9 +365,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 04f38e0fa81928b9c3cddefd0139fe8e862c681c Mon Sep 17 00:00:00 2001 From: ochafik Date: Wed, 3 Dec 2025 17:52:27 +0100 Subject: [PATCH 08/17] rm zod-to-json-schema --- src/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 397aa314..4297b470 100644 --- a/src/app.ts +++ b/src/app.ts @@ -62,7 +62,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"; export * from "./types"; From 402403836dda1bf1e17cbeb73553391cc8e047f0 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 9 Jan 2026 16:32:19 +0000 Subject: [PATCH 09/17] fix: Update RegisteredTool to use 'handler' instead of 'callback' (SDK API change) --- src/app-bridge.test.ts | 10 +++++----- src/app.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index e4473d18..4e95ab23 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -731,7 +731,7 @@ describe("App <-> AppBridge integration", () => { sendRequest: async () => ({}), } as any; - await expect((tool.callback as any)(mockExtra)).rejects.toThrow( + await expect((tool.handler as any)(mockExtra)).rejects.toThrow( "Tool test-tool is disabled", ); }); @@ -759,12 +759,12 @@ describe("App <-> AppBridge integration", () => { // Valid input should work await expect( - (tool.callback as any)({ name: "Alice" }, mockExtra), + (tool.handler as any)({ name: "Alice" }, mockExtra), ).resolves.toBeDefined(); // Invalid input should fail await expect( - (tool.callback as any)({ invalid: "field" }, mockExtra), + (tool.handler as any)({ invalid: "field" }, mockExtra), ).rejects.toThrow("Invalid input for tool greet"); }); @@ -791,7 +791,7 @@ describe("App <-> AppBridge integration", () => { } as any; // Valid output should work - await expect((tool.callback as any)(mockExtra)).resolves.toBeDefined(); + await expect((tool.handler as any)(mockExtra)).resolves.toBeDefined(); }); it("tool enable/disable/update/remove trigger sendToolListChanged", async () => { @@ -932,7 +932,7 @@ describe("App <-> AppBridge integration", () => { app.oncalltool = async (params, extra) => { if (params.name === "greet") { - return await (tool.callback as any)(params.arguments || {}, extra); + return await (tool.handler as any)(params.arguments || {}, extra); } throw new Error(`Unknown tool: ${params.name}`); }; diff --git a/src/app.ts b/src/app.ts index 4297b470..4591f794 100644 --- a/src/app.ts +++ b/src/app.ts @@ -304,7 +304,7 @@ export class App extends Protocol { delete app._registeredTools[name]; app.sendToolListChanged(); }, - callback: (async (args: any, extra: RequestHandlerExtra) => { + handler: (async (args: any, extra: RequestHandlerExtra) => { if (!registeredTool.enabled) { throw new Error(`Tool ${name} is disabled`); } @@ -320,7 +320,7 @@ export class App extends Protocol { } args = parseResult.data; } - const result = await cb(args, extra); + const result = await cb(args, extra as any); if (config.outputSchema) { const parseResult = await safeParseAsync( config.outputSchema as any, @@ -355,7 +355,7 @@ export class App extends Protocol { if (!tool) { throw new Error(`Tool ${params.name} not found`); } - return tool.callback(params.arguments as any, extra); + return (tool.handler as any)(params.arguments as any, extra); }; this.onlisttools = async (_params, _extra) => { const tools: Tool[] = Object.entries(this._registeredTools) From b705c14136afdec9dd80ba0e45453679e9ea11bb Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 9 Jan 2026 16:57:58 +0000 Subject: [PATCH 10/17] refactor: Rename sendCallTool/sendListTools to callTool/listTools Avoid double-verb naming pattern for consistency with existing API. --- src/app-bridge.test.ts | 72 +++++++++++++++++++++--------------------- src/app-bridge.ts | 4 +-- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 4e95ab23..6f0efb45 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -818,7 +818,7 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); }); - it("bridge.sendCallTool calls app.oncalltool handler", async () => { + it("bridge.callTool calls app.oncalltool handler", async () => { // App needs tool capabilities to handle tool calls const appCapabilities = { tools: {} }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); @@ -834,7 +834,7 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); - const result = await bridge.sendCallTool({ + const result = await bridge.callTool({ name: "test-tool", arguments: { foo: "bar" }, }); @@ -849,7 +849,7 @@ describe("App <-> AppBridge integration", () => { ]); }); - it("bridge.sendListTools calls app.onlisttools handler", async () => { + it("bridge.listTools calls app.onlisttools handler", async () => { // App needs tool capabilities to handle tool list requests const appCapabilities = { tools: {} }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); @@ -881,7 +881,7 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); - const result = await bridge.sendListTools({}); + const result = await bridge.listTools({}); expect(receivedCalls).toHaveLength(1); expect(result.tools).toHaveLength(3); @@ -907,7 +907,7 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - await bridge.sendCallTool({ + await bridge.callTool({ name: "test-tool", arguments: {}, }); @@ -940,7 +940,7 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - const result = await bridge.sendCallTool({ + const result = await bridge.callTool({ name: "greet", arguments: { name: "Alice" }, }); @@ -974,7 +974,7 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // Call the tool through bridge - should work automatically - const result = await bridge.sendCallTool({ + const result = await bridge.callTool({ name: "greet", arguments: { name: "Bob" }, }); @@ -995,7 +995,7 @@ describe("App <-> AppBridge integration", () => { // Try to call a tool that doesn't exist await expect( - bridge.sendCallTool({ + bridge.callTool({ name: "nonexistent", arguments: {}, }), @@ -1044,7 +1044,7 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // Call first tool - const addResult = await bridge.sendCallTool({ + const addResult = await bridge.callTool({ name: "add", arguments: { a: 5, b: 3 }, }); @@ -1053,7 +1053,7 @@ describe("App <-> AppBridge integration", () => { ]); // Call second tool - const multiplyResult = await bridge.sendCallTool({ + const multiplyResult = await bridge.callTool({ name: "multiply", arguments: { a: 5, b: 3 }, }); @@ -1080,7 +1080,7 @@ describe("App <-> AppBridge integration", () => { // Should work when enabled await expect( - bridge.sendCallTool({ name: "test-tool", arguments: {} }), + bridge.callTool({ name: "test-tool", arguments: {} }), ).resolves.toBeDefined(); // Disable tool @@ -1088,7 +1088,7 @@ describe("App <-> AppBridge integration", () => { // Should throw when disabled await expect( - bridge.sendCallTool({ name: "test-tool", arguments: {} }), + bridge.callTool({ name: "test-tool", arguments: {} }), ).rejects.toThrow("Tool test-tool is disabled"); }); @@ -1114,7 +1114,7 @@ describe("App <-> AppBridge integration", () => { // Valid input should work await expect( - bridge.sendCallTool({ + bridge.callTool({ name: "strict-tool", arguments: { required: "hello" }, }), @@ -1122,7 +1122,7 @@ describe("App <-> AppBridge integration", () => { // Invalid input should fail await expect( - bridge.sendCallTool({ + bridge.callTool({ name: "strict-tool", arguments: { wrong: "field" }, }), @@ -1150,7 +1150,7 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // Valid output should work - const result = await bridge.sendCallTool({ + const result = await bridge.callTool({ name: "validated-output", arguments: {}, }); @@ -1172,7 +1172,7 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // First version - let result = await bridge.sendCallTool({ + let result = await bridge.callTool({ name: "dynamic-tool", arguments: {}, }); @@ -1183,7 +1183,7 @@ describe("App <-> AppBridge integration", () => { // Should fail after removal await expect( - bridge.sendCallTool({ name: "dynamic-tool", arguments: {} }), + bridge.callTool({ name: "dynamic-tool", arguments: {} }), ).rejects.toThrow("Tool dynamic-tool not found"); // Re-register with different behavior @@ -1192,7 +1192,7 @@ describe("App <-> AppBridge integration", () => { })); // Should work with new version - result = await bridge.sendCallTool({ + result = await bridge.callTool({ name: "dynamic-tool", arguments: {}, }); @@ -1218,7 +1218,7 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); - const result = await bridge.sendListTools({}); + const result = await bridge.listTools({}); expect(result.tools).toHaveLength(3); expect(result.tools.map((t) => t.name)).toContain("tool1"); @@ -1241,7 +1241,7 @@ describe("App <-> AppBridge integration", () => { // Remove the tool after connecting dummyTool.remove(); - const result = await bridge.sendListTools({}); + const result = await bridge.listTools({}); expect(result.tools).toEqual([]); }); @@ -1260,7 +1260,7 @@ describe("App <-> AppBridge integration", () => { dummy.remove(); // Initially no tools - let result = await bridge.sendListTools({}); + let result = await bridge.listTools({}); expect(result.tools).toEqual([]); // Add a tool @@ -1269,7 +1269,7 @@ describe("App <-> AppBridge integration", () => { })); // Should now include the new tool - result = await bridge.sendListTools({}); + result = await bridge.listTools({}); expect(result.tools.map((t) => t.name)).toEqual(["new-tool"]); // Add another tool @@ -1278,7 +1278,7 @@ describe("App <-> AppBridge integration", () => { })); // Should now include both tools - result = await bridge.sendListTools({}); + result = await bridge.listTools({}); 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"); @@ -1301,14 +1301,14 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // Initially all three tools - let result = await bridge.sendListTools({}); + let result = await bridge.listTools({}); expect(result.tools).toHaveLength(3); // Remove one tool tool2.remove(); // Should now have two tools - result = await bridge.sendListTools({}); + result = await bridge.listTools({}); expect(result.tools).toHaveLength(2); expect(result.tools.map((t) => t.name)).toContain("tool1"); expect(result.tools.map((t) => t.name)).toContain("tool3"); @@ -1318,7 +1318,7 @@ describe("App <-> AppBridge integration", () => { tool1.remove(); // Should now have one tool - result = await bridge.sendListTools({}); + result = await bridge.listTools({}); expect(result.tools.map((t) => t.name)).toEqual(["tool3"]); }); @@ -1346,7 +1346,7 @@ describe("App <-> AppBridge integration", () => { // Disable one tool after connecting tool2.disable(); - const result = await bridge.sendListTools({}); + const result = await bridge.listTools({}); // Only enabled tool should be in the list expect(result.tools).toHaveLength(1); @@ -1375,11 +1375,11 @@ describe("App <-> AppBridge integration", () => { ); // List should include the tool - let listResult = await bridge.sendListTools({}); + let listResult = await bridge.listTools({}); expect(listResult.tools.map((t) => t.name)).toContain("counter"); // Call the tool - let callResult = await bridge.sendCallTool({ + let callResult = await bridge.callTool({ name: "counter", arguments: {}, }); @@ -1391,7 +1391,7 @@ describe("App <-> AppBridge integration", () => { tool.update({ description: "An updated counter tool" }); // Should still be callable - callResult = await bridge.sendCallTool({ + callResult = await bridge.callTool({ name: "counter", arguments: {}, }); @@ -1401,12 +1401,12 @@ describe("App <-> AppBridge integration", () => { tool.remove(); // Should no longer be in list - listResult = await bridge.sendListTools({}); + listResult = await bridge.listTools({}); expect(listResult.tools.map((t) => t.name)).not.toContain("counter"); // Should no longer be callable await expect( - bridge.sendCallTool({ name: "counter", arguments: {} }), + bridge.callTool({ name: "counter", arguments: {} }), ).rejects.toThrow("Tool counter not found"); }); @@ -1457,19 +1457,19 @@ describe("App <-> AppBridge integration", () => { await app2.connect(app2Transport); // Each app should only see its own tools - const list1 = await bridge1.sendListTools({}); + const list1 = await bridge1.listTools({}); expect(list1.tools.map((t) => t.name)).toEqual(["app1-tool"]); - const list2 = await bridge2.sendListTools({}); + const list2 = await bridge2.listTools({}); 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: {} }), + bridge1.callTool({ name: "app1-tool", arguments: {} }), ).resolves.toBeDefined(); await expect( - bridge1.sendCallTool({ name: "app2-tool", arguments: {} }), + bridge1.callTool({ name: "app2-tool", arguments: {} }), ).rejects.toThrow("Tool app2-tool not found"); // Clean up diff --git a/src/app-bridge.ts b/src/app-bridge.ts index de6a4d75..cd51b477 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -1336,7 +1336,7 @@ export class AppBridge extends Protocol< /** @deprecated Use {@link teardownResource} instead */ sendResourceTeardown: AppBridge["teardownResource"] = this.teardownResource; - sendCallTool(params: CallToolRequest["params"], options?: RequestOptions) { + callTool(params: CallToolRequest["params"], options?: RequestOptions) { return this.request( { method: "tools/call", params }, CallToolResultSchema, @@ -1344,7 +1344,7 @@ export class AppBridge extends Protocol< ); } - sendListTools(params: ListToolsRequest["params"], options?: RequestOptions) { + listTools(params: ListToolsRequest["params"], options?: RequestOptions) { return this.request( { method: "tools/list", params }, ListToolsResultSchema, From 4b23e1ec574dcb3fd41536d85a5e21537af84347 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sat, 17 Jan 2026 03:04:19 +0000 Subject: [PATCH 11/17] feat: Add screenshot and click support to SDK - Add McpUiScreenshotRequest/Result and McpUiClickRequest/Result types - Add onscreenshot and onclick handlers to App class - Add screenshot() and click() methods to AppBridge class - Generate updated Zod schemas --- package-lock.json | 682 ++++++++++------------------------- src/app-bridge.ts | 76 ++++ src/app.ts | 77 ++++ src/generated/schema.json | 152 ++++++++ src/generated/schema.test.ts | 32 ++ src/generated/schema.ts | 116 ++++++ src/spec.types.ts | 77 ++++ src/types.ts | 14 + 8 files changed, 733 insertions(+), 493 deletions(-) diff --git a/package-lock.json b/package-lock.json index 405f3148..435ffc6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,21 +95,6 @@ "vitest": "^3.2.4" } }, - "examples/basic-host/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/basic-host/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/basic-server-preact": { "name": "@modelcontextprotocol/server-basic-preact", "version": "0.4.1", @@ -137,21 +122,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/basic-server-preact/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/basic-server-preact/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/basic-server-react": { "name": "@modelcontextprotocol/server-basic-react", "version": "0.4.1", @@ -182,21 +152,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/basic-server-react/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/basic-server-react/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/basic-server-solid": { "name": "@modelcontextprotocol/server-basic-solid", "version": "0.4.1", @@ -224,21 +179,6 @@ "vite-plugin-solid": "^2.0.0" } }, - "examples/basic-server-solid/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/basic-server-solid/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/basic-server-svelte": { "name": "@modelcontextprotocol/server-basic-svelte", "version": "0.4.1", @@ -266,21 +206,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/basic-server-svelte/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/basic-server-svelte/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/basic-server-vanillajs": { "name": "@modelcontextprotocol/server-basic-vanillajs", "version": "0.4.1", @@ -306,21 +231,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/basic-server-vanillajs/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/basic-server-vanillajs/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/basic-server-vue": { "name": "@modelcontextprotocol/server-basic-vue", "version": "0.4.1", @@ -348,21 +258,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/basic-server-vue/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/basic-server-vue/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/budget-allocator-server": { "name": "@modelcontextprotocol/server-budget-allocator", "version": "0.4.1", @@ -389,21 +284,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/budget-allocator-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/budget-allocator-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/cohort-heatmap-server": { "name": "@modelcontextprotocol/server-cohort-heatmap", "version": "0.4.1", @@ -434,21 +314,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/cohort-heatmap-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/cohort-heatmap-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/customer-segmentation-server": { "name": "@modelcontextprotocol/server-customer-segmentation", "version": "0.4.1", @@ -475,21 +340,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/customer-segmentation-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/customer-segmentation-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/integration-server": { "version": "1.0.0", "dependencies": { @@ -517,21 +367,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/integration-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/integration-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/map-server": { "name": "@modelcontextprotocol/server-map", "version": "0.4.1", @@ -557,21 +392,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/map-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/map-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/pdf-server": { "name": "@modelcontextprotocol/server-pdf", "version": "0.4.1", @@ -598,21 +418,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/pdf-server/node_modules/@types/node": { - "version": "22.19.6", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/pdf-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/qr-server": { "name": "@modelcontextprotocol/server-qr", "version": "1.0.0" @@ -648,21 +453,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/scenario-modeler-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/scenario-modeler-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/shadertoy-server": { "name": "@modelcontextprotocol/server-shadertoy", "version": "0.4.1", @@ -688,21 +478,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/shadertoy-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/shadertoy-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/sheet-music-server": { "name": "@modelcontextprotocol/server-sheet-music", "version": "0.4.1", @@ -729,21 +504,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/sheet-music-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/sheet-music-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/system-monitor-server": { "name": "@modelcontextprotocol/server-system-monitor", "version": "0.4.1", @@ -771,21 +531,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/system-monitor-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/system-monitor-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/threejs-server": { "name": "@modelcontextprotocol/server-threejs", "version": "0.4.1", @@ -818,21 +563,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/threejs-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/threejs-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/transcript-server": { "name": "@modelcontextprotocol/server-transcript", "version": "0.4.1", @@ -859,21 +589,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/transcript-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/transcript-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/transcript-server/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -908,21 +623,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/video-resource-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/video-resource-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "examples/wiki-explorer-server": { "name": "@modelcontextprotocol/server-wiki-explorer", "version": "0.4.1", @@ -950,21 +650,6 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/wiki-explorer-server/node_modules/@types/node": { - "version": "22.19.5", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "examples/wiki-explorer-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -2386,8 +2071,47 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/ext-apps": { - "resolved": "", - "link": true + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-0.4.1.tgz", + "integrity": "sha512-LUw6NidwWInzWVF8OSPw/Mtdz5ES2qF+yBze2h+WRARdSbXf+agTkZLCGFtdkogI64W6mDlJnSTp/k5W+FZ84A==", + "hasInstallScript": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@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-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } }, "node_modules/@modelcontextprotocol/ext-apps-basic-host": { "resolved": "examples/basic-host", @@ -3670,13 +3394,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", - "integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==", + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/qs": { @@ -4319,9 +4043,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", + "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5061,9 +4785,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.1.tgz", - "integrity": "sha512-ryitBnaRbXQtgZ/gU50GSn6jQRwinSCQclpakXymvLd8ytTgE5bmSfgYcUxD7XYL34qHhFDyVk71qqKsfSyvmA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "dev": true, "license": "ISC", "engines": { @@ -5271,9 +4995,9 @@ } }, "node_modules/devalue": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", - "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "license": "MIT" }, "node_modules/dom-serializer": { @@ -5696,6 +5420,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -6016,16 +5758,6 @@ "he": "bin/he" } }, - "node_modules/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-entities": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", @@ -7180,9 +6912,9 @@ } }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz", + "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "license": "MIT", "bin": { @@ -7952,9 +7684,9 @@ } }, "node_modules/svelte": { - "version": "5.46.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.3.tgz", - "integrity": "sha512-Y5juST3x+/ySty5tYJCVWa6Corkxpt25bUZQHqOceg9xfMUtDsFx6rCsG6cYf1cA6vzDi66HIvaki0byZZX95A==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.4.tgz", + "integrity": "sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -7965,7 +7697,7 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.5.0", + "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", @@ -7978,9 +7710,9 @@ } }, "node_modules/systeminformation": { - "version": "5.30.3", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.3.tgz", - "integrity": "sha512-NgHJUpA+y7j4asLQa9jgBt+Eb2piyQIXQ+YjOyd2K0cHNwbNJ6I06F5afOqOiaCuV/wrEyGrb0olg4aFLlJD+A==", + "version": "5.30.5", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.5.tgz", + "integrity": "sha512-DpWmpCckhwR3hG+6udb6/aQB7PpiqVnvSljrjbKxNSvTRsGsg7NVE3/vouoYf96xgwMxXFKcS4Ux+cnkFwYM7A==", "license": "MIT", "os": [ "darwin", @@ -8004,214 +7736,214 @@ } }, "node_modules/text-camel-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-camel-case/-/text-camel-case-1.2.9.tgz", - "integrity": "sha512-wKYs9SgRxYizJE1mneR7BbLNlGw2IYzJAS8XwkWIry0CTbO1gvvPkFsx5Z1/hr+VqUaBqx9q3yKd30HpZLdMsQ==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-camel-case/-/text-camel-case-1.2.10.tgz", + "integrity": "sha512-KNrWeZzQT+gh73V1LnmgTkjK7V+tMRjLCc6VrGwkqbiRdnGVIWBUgIvVnvnaVCxIvZ/2Ke8DCmgPirlQcCqD3Q==", "dev": true, "license": "MIT", "dependencies": { - "text-pascal-case": "1.2.9" + "text-pascal-case": "1.2.10" } }, "node_modules/text-capital-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-capital-case/-/text-capital-case-1.2.9.tgz", - "integrity": "sha512-X5zV8U8pxtq2xS2t46lgAWqZdDbgWMKq03MQSNwY2CJdQCsdTNh144E2Q/q9wBxWzSBUXn+jRc9kF+Gs8/pGhA==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-capital-case/-/text-capital-case-1.2.10.tgz", + "integrity": "sha512-yvViUJKSSQcRO58je224bhPHg/Hij9MEY43zuKShtFzrPwW/fOAarUJ5UkTMSB81AOO1m8q+JiFdxMF4etKZbA==", "dev": true, "license": "MIT", "dependencies": { - "text-no-case": "1.2.9", - "text-upper-case-first": "1.2.9" + "text-no-case": "1.2.10", + "text-upper-case-first": "1.2.10" } }, "node_modules/text-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-case/-/text-case-1.2.9.tgz", - "integrity": "sha512-zZVdA8rMcjx9zhekdUuOPZShc25UTV7W8/ddKbgbPtfCEvIiToPtWiSd2lXLSuiGMovNhJ4+Tw49xll9o9ts+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "text-camel-case": "1.2.9", - "text-capital-case": "1.2.9", - "text-constant-case": "1.2.9", - "text-dot-case": "1.2.9", - "text-header-case": "1.2.9", - "text-is-lower-case": "1.2.9", - "text-is-upper-case": "1.2.9", - "text-kebab-case": "1.2.9", - "text-lower-case": "1.2.9", - "text-lower-case-first": "1.2.9", - "text-no-case": "1.2.9", - "text-param-case": "1.2.9", - "text-pascal-case": "1.2.9", - "text-path-case": "1.2.9", - "text-sentence-case": "1.2.9", - "text-snake-case": "1.2.9", - "text-swap-case": "1.2.9", - "text-title-case": "1.2.9", - "text-upper-case": "1.2.9", - "text-upper-case-first": "1.2.9" + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-case/-/text-case-1.2.10.tgz", + "integrity": "sha512-5bY3Ks/u7OJ5YO69iyXrG5Xf2wUZeyko7U78nPUnYoSeuNeAfA5uAix5hTspfkl6smm3yCBObrex+kFvzeIcJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-camel-case": "1.2.10", + "text-capital-case": "1.2.10", + "text-constant-case": "1.2.10", + "text-dot-case": "1.2.10", + "text-header-case": "1.2.10", + "text-is-lower-case": "1.2.10", + "text-is-upper-case": "1.2.10", + "text-kebab-case": "1.2.10", + "text-lower-case": "1.2.10", + "text-lower-case-first": "1.2.10", + "text-no-case": "1.2.10", + "text-param-case": "1.2.10", + "text-pascal-case": "1.2.10", + "text-path-case": "1.2.10", + "text-sentence-case": "1.2.10", + "text-snake-case": "1.2.10", + "text-swap-case": "1.2.10", + "text-title-case": "1.2.10", + "text-upper-case": "1.2.10", + "text-upper-case-first": "1.2.10" } }, "node_modules/text-constant-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-constant-case/-/text-constant-case-1.2.9.tgz", - "integrity": "sha512-Vosm6nC7Gag+JFakJHwqS9AXRNgl07j5KZ7srU9cYuKRzYwrxzeJ4RpEogRBNHw7CfmOm0j5FGEznblWtu7pIw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-constant-case/-/text-constant-case-1.2.10.tgz", + "integrity": "sha512-/OfU798O2wrwKN9kQf71WhJeAlklGnbby0Tupp+Ez9NXymW+6oF9LWDRTkN+OreTmHucdvp4WQd6O5Rah5zj8A==", "dev": true, "license": "MIT", "dependencies": { - "text-no-case": "1.2.9", - "text-upper-case": "1.2.9" + "text-no-case": "1.2.10", + "text-upper-case": "1.2.10" } }, "node_modules/text-dot-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-dot-case/-/text-dot-case-1.2.9.tgz", - "integrity": "sha512-N83hsnvGdSO9q9AfNSB9Cy1LFDNN2MCx53LcxtaPoDWPUTk47fv0JlvIY1tgY0wyzCiThF03kVj3jworvAOScA==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-dot-case/-/text-dot-case-1.2.10.tgz", + "integrity": "sha512-vf4xguy5y6e39RlDZeWZFMDf2mNkR23VTSVb9e68dUSpfJscG9/1YWWpW3n8TinzQxBZlsn5sT5olL33MvvQXw==", "dev": true, "license": "MIT", "dependencies": { - "text-no-case": "1.2.9" + "text-no-case": "1.2.10" } }, "node_modules/text-header-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-header-case/-/text-header-case-1.2.9.tgz", - "integrity": "sha512-TqryEKcYisQAfWLbtT3xPnZlMZ/mySO1uS+LUg+B0eNuqgETrSzVpXIUj5E6Zf/EyJHgpZf4VndbAXtOMJuT4w==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-header-case/-/text-header-case-1.2.10.tgz", + "integrity": "sha512-sVb1NY9bwxtu+Z7CVyWbr+I0AkWtF0kEHL/Zz5V2u/WdkjK5tKBwl5nXf0NGy9da4ZUYTBb+TmQpOIqihzvFMQ==", "dev": true, "license": "MIT", "dependencies": { - "text-capital-case": "1.2.9" + "text-capital-case": "1.2.10" } }, "node_modules/text-is-lower-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-is-lower-case/-/text-is-lower-case-1.2.9.tgz", - "integrity": "sha512-cEurrWSnYVYqL8FSwl5cK4mdfqF7qNDCcKJgXI3NnfTesiB8umxAhdlQoErrRYI1xEvYr2WN0MI333EehUhQjg==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-is-lower-case/-/text-is-lower-case-1.2.10.tgz", + "integrity": "sha512-dMTeTgrdWWfYf3fKxvjMkDPuXWv96cWbd1Uym6Zjv9H855S1uHxjkFsGbTYJ2tEK0NvAylRySTQlI6axlcMc4w==", "dev": true, "license": "MIT" }, "node_modules/text-is-upper-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-is-upper-case/-/text-is-upper-case-1.2.9.tgz", - "integrity": "sha512-HxsWr3VCsXXiLlhD0c+Ey+mS2lOTCiSJbkepjaXNHl2bp33KiscQaiG0qLwQmmpZQm4SJCg2s9FkndxS0RNDLQ==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-is-upper-case/-/text-is-upper-case-1.2.10.tgz", + "integrity": "sha512-PGD/cXoXECGAY1HVZxDdmpJUW2ZUAKQ6DTamDfCHC9fc/z4epOz0pB/ThBnjJA3fz+d2ApkMjAfZDjuZFcodzg==", "dev": true, "license": "MIT" }, "node_modules/text-kebab-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-kebab-case/-/text-kebab-case-1.2.9.tgz", - "integrity": "sha512-nOUyNR5Ej2B9D/wyyXfwUEv26+pQuOb1pEX+ojE37mCIWo8QeOxw5y6nxuqDmG7NrEPzbO6265UMV+EICH13Cw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-kebab-case/-/text-kebab-case-1.2.10.tgz", + "integrity": "sha512-3XZJAApx5JQpUO7eXo7GQ2TyRcGw3OVbqxz6QJb2h+N8PbLLbz3zJVeXdGrhTkoUIbkSZ6PmHx6LRDaHXTdMcA==", "dev": true, "license": "MIT", "dependencies": { - "text-no-case": "1.2.9" + "text-no-case": "1.2.10" } }, "node_modules/text-lower-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-lower-case/-/text-lower-case-1.2.9.tgz", - "integrity": "sha512-53AOnDrhPpiAUQkgY1SHleKUXp/u7GsqRX13NcCREZscmtjLLJ099uxMRjkK7q2KwHkFYVPl9ytkQlTkTQLS0w==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-lower-case/-/text-lower-case-1.2.10.tgz", + "integrity": "sha512-c9j5pIAN3ObAp1+4R7970e1bgtahTRF/5ZQdX2aJBuBngYTYZZIck0NwFXUKk5BnYpLGsre5KFHvpqvf4IYKgg==", "dev": true, "license": "MIT" }, "node_modules/text-lower-case-first": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-lower-case-first/-/text-lower-case-first-1.2.9.tgz", - "integrity": "sha512-iiphHTV7PVH0MljrEQUA9iBE7jfDpXoi4RQju3WzZU3BRVbS6540cNZgxR19hWa0z6z/7cJTH0Ls9LPBaiUfKg==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-lower-case-first/-/text-lower-case-first-1.2.10.tgz", + "integrity": "sha512-Oro84jZPDLD9alfdZWmtFHYTvCaaSz2o4thPtjMsK4GAkTyVg9juYXWj0y0YFyjLYGH69muWsBe4/MR5S7iolw==", "dev": true, "license": "MIT" }, "node_modules/text-no-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-no-case/-/text-no-case-1.2.9.tgz", - "integrity": "sha512-IcCt328KaapimSrytP4ThfC8URmHZb2DgOqCL9BYvGjpxY2lDiqCkIQk9sClZtwcELs2gTnq83a7jNc573FTLA==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-no-case/-/text-no-case-1.2.10.tgz", + "integrity": "sha512-4/m79pzQrywrwEG5lCULY1lQvFY+EKjhH9xSMT6caPK5plqzm9Y7rXyv+UXPd3s9qH6QODZnvsAYWW3M0JgxRA==", "dev": true, "license": "MIT", "dependencies": { - "text-lower-case": "1.2.9" + "text-lower-case": "1.2.10" } }, "node_modules/text-param-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-param-case/-/text-param-case-1.2.9.tgz", - "integrity": "sha512-nR/Ju9amY3aQS1en2CUCgqN/ZiZIVdDyjlJ3xX5J92ChBevGuA4o9K10fh3JGMkbzK97Vcb+bWQJ4Q+Svz+GyQ==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-param-case/-/text-param-case-1.2.10.tgz", + "integrity": "sha512-hkavcLsRRzZcGryPAshct1AwIOMj/FexYjMaLpGZCYYBn1lcZEeyMzJZPSckzkOYpq35LYSQr3xZto9XU5OAsw==", "dev": true, "license": "MIT", "dependencies": { - "text-dot-case": "1.2.9" + "text-dot-case": "1.2.10" } }, "node_modules/text-pascal-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-pascal-case/-/text-pascal-case-1.2.9.tgz", - "integrity": "sha512-o6ZxMGjWDTUW54pcghpXes+C2PqbYRMdU5mHrIhueb6z6nq1NueiIOeCUdrSjN/3wXfhCmnFjK7/d9aRGZNqSg==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-pascal-case/-/text-pascal-case-1.2.10.tgz", + "integrity": "sha512-/kynZD8vTYOmm/RECjIDaz3qYEUZc/N/bnC79XuAFxwXjdNVjj/jGovKJLRzqsYK/39N22XpGcVmGg7yIrbk6w==", "dev": true, "license": "MIT", "dependencies": { - "text-no-case": "1.2.9" + "text-no-case": "1.2.10" } }, "node_modules/text-path-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-path-case/-/text-path-case-1.2.9.tgz", - "integrity": "sha512-s8cJ6r5TkJp5ticXMgtxd7f12odEN4d1CfX5u4aoz6jcUtBR2lDqzIhVimkqWFMJ4UKPSrmilUha8Xc2BPi+ow==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-path-case/-/text-path-case-1.2.10.tgz", + "integrity": "sha512-vbKdRCaVEeOaW6sm24QP9NbH7TS9S4ZQ3u19H8eylDox7m2HtFwYIBjAPv+v3z4I/+VjrMy9LB54lNP1uEqRHw==", "dev": true, "license": "MIT", "dependencies": { - "text-dot-case": "1.2.9" + "text-dot-case": "1.2.10" } }, "node_modules/text-sentence-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-sentence-case/-/text-sentence-case-1.2.9.tgz", - "integrity": "sha512-/G/Yi5kZfUa1edFRV4O3lGZAkbDZTFvlwW8CYfH7szkEGe2k2MYEYbOyAkGRVQEGV6V6JiuUAaP3VS9c1tB6nQ==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-sentence-case/-/text-sentence-case-1.2.10.tgz", + "integrity": "sha512-NO4MRlbfxFhl9QgQLuCL4xHmvE7PUWHVPWsZxQ5nzRtDjXOUllWvtsvl8CP5tBEvBmzg0kwfflxfhRtr5vBQGg==", "dev": true, "license": "MIT", "dependencies": { - "text-no-case": "1.2.9", - "text-upper-case-first": "1.2.9" + "text-no-case": "1.2.10", + "text-upper-case-first": "1.2.10" } }, "node_modules/text-snake-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-snake-case/-/text-snake-case-1.2.9.tgz", - "integrity": "sha512-+ZrqK19ynF/TLQZ7ynqVrL2Dy04uu9syYZwsm8PhzUdsY3XrwPy6QiRqhIEFqhyWbShPcfyfmheer5UEQqFxlw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-snake-case/-/text-snake-case-1.2.10.tgz", + "integrity": "sha512-6ttMZ+B9jkHKun908HYr4xSvEtlbfJJ4MvpQ06JEKRGhwjMI0x8t2Wywp+MEzN6142O6E/zKhra18KyBL6cvXA==", "dev": true, "license": "MIT", "dependencies": { - "text-dot-case": "1.2.9" + "text-dot-case": "1.2.10" } }, "node_modules/text-swap-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-swap-case/-/text-swap-case-1.2.9.tgz", - "integrity": "sha512-g5fp12ldktYKK9wdHRMvvtSCQrZYNv/D+ZGLumDsvAY4q9T5bCMO2IWMkIP1F5gVQrysdHH6Xv877P/pjUq1iw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-swap-case/-/text-swap-case-1.2.10.tgz", + "integrity": "sha512-vO3jwInIk0N77oEFakYZ2Hn/llTmRwf2c3RvkX/LfvmLWVp+3QcIc6bwUEtbqGQ5Xh2okjFhYrfkHZstVc3N4Q==", "dev": true, "license": "MIT" }, "node_modules/text-title-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-title-case/-/text-title-case-1.2.9.tgz", - "integrity": "sha512-RAtC9cdmPp41ns5/HXZBsaQg71BsHT7uZpj2ojTtuFa8o2dNuRYYOrSmy5YdLRIAJQ6WK5hQVpV3jHuq7a+4Tw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-title-case/-/text-title-case-1.2.10.tgz", + "integrity": "sha512-bqA+WWexUMWu9A3fdNar+3GXXW+c5xOvMyuK5hOx/w0AlqhyQptyCrMFjGB8Fd9dxbryBNmJ+5rWtC1OBDxlaA==", "dev": true, "license": "MIT", "dependencies": { - "text-no-case": "1.2.9", - "text-upper-case-first": "1.2.9" + "text-no-case": "1.2.10", + "text-upper-case-first": "1.2.10" } }, "node_modules/text-upper-case": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-upper-case/-/text-upper-case-1.2.9.tgz", - "integrity": "sha512-K/0DNT7a4z8eah2spARtoJllTZyrNTo6Uc0ujhN/96Ir9uJ/slpahfs13y46H9osL3daaLl3O7iXOkW4xtX6bg==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-upper-case/-/text-upper-case-1.2.10.tgz", + "integrity": "sha512-L1AtZ8R+jtSMTq0Ffma9R4Rzbrc3iuYW89BmWFH41AwnDfRmEBlBOllm1ZivRLQ/6pEu2p+3XKBHx9fsMl2CWg==", "dev": true, "license": "MIT" }, "node_modules/text-upper-case-first": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/text-upper-case-first/-/text-upper-case-first-1.2.9.tgz", - "integrity": "sha512-wEDD1B6XqJmEV+xEnBJd+2sBCHZ+7fvA/8Rv/o8+dAsp05YWjYP/kjB8sPH6zqzW0s6jtehIg4IlcKjcYxk2CQ==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/text-upper-case-first/-/text-upper-case-first-1.2.10.tgz", + "integrity": "sha512-VXs7j7BbpKwvolDh5fwpYRmMrUHGkxbY8E90fhBzKUoKfadvWmPT/jFieoZ4UPLzr208pXvQEFbb2zO9Qzs9Fg==", "dev": true, "license": "MIT" }, @@ -8259,24 +7991,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -9030,7 +8744,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9064,9 +8778,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -9277,24 +8991,6 @@ "vite": "5.x || 6.x || 7.x" } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", diff --git a/src/app-bridge.ts b/src/app-bridge.ts index cd51b477..f0d9ea4f 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -81,6 +81,12 @@ import { McpUiRequestDisplayModeRequestSchema, McpUiRequestDisplayModeResult, McpUiResourcePermissions, + McpUiScreenshotRequest, + McpUiScreenshotResult, + McpUiScreenshotResultSchema, + McpUiClickRequest, + McpUiClickResult, + McpUiClickResultSchema, } from "./types"; export * from "./types"; export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app"; @@ -1352,6 +1358,76 @@ export class AppBridge extends Protocol< ); } + /** + * Capture a screenshot of the Guest UI. + * + * Requests the App to render and capture its current visual state. The App + * returns the image as a base64-encoded string. + * + * @param params - Screenshot options (format, quality) + * @param options - Request options (timeout, etc.) + * @returns Promise resolving to the screenshot data + * + * @throws {Error} If the App does not support screenshots + * @throws {Error} If the request times out or the connection is lost + * + * @example Capture a PNG screenshot + * ```typescript + * const result = await bridge.screenshot({ format: "png" }); + * const img = document.createElement("img"); + * img.src = `data:${result.mimeType};base64,${result.data}`; + * document.body.appendChild(img); + * ``` + */ + async screenshot( + params?: McpUiScreenshotRequest["params"], + options?: RequestOptions, + ): Promise { + return this.request( + { + method: "ui/screenshot" as const, + params: params ?? {}, + }, + McpUiScreenshotResultSchema, + options, + ); + } + + /** + * Simulate a click at a specific position in the Guest UI. + * + * Requests the App to dispatch a mouse event at the specified coordinates. + * The App should handle this as if the user clicked at that position. + * + * @param params - Click coordinates and options + * @param options - Request options (timeout, etc.) + * @returns Promise resolving to the click result + * + * @throws {Error} If the App does not support click simulation + * @throws {Error} If the request times out or the connection is lost + * + * @example Simple left click + * ```typescript + * const result = await bridge.click({ x: 100, y: 200 }); + * if (result.success) { + * console.log(`Clicked on: ${result.targetElement}`); + * } + * ``` + */ + async click( + params: McpUiClickRequest["params"], + options?: RequestOptions, + ): Promise { + return this.request( + { + method: "ui/click" as const, + params, + }, + McpUiClickResultSchema, + options, + ); + } + /** * Connect to the Guest UI via transport and optionally set up message forwarding. * diff --git a/src/app.ts b/src/app.ts index 4591f794..f8149669 100644 --- a/src/app.ts +++ b/src/app.ts @@ -28,6 +28,9 @@ import { PostMessageTransport } from "./message-transport"; import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, + McpUiClickRequest, + McpUiClickRequestSchema, + McpUiClickResult, McpUiUpdateModelContextRequest, McpUiHostCapabilities, McpUiHostContext, @@ -43,6 +46,9 @@ import { McpUiResourceTeardownRequest, McpUiResourceTeardownRequestSchema, McpUiResourceTeardownResult, + McpUiScreenshotRequest, + McpUiScreenshotRequestSchema, + McpUiScreenshotResult, McpUiSizeChangedNotification, McpUiToolCancelledNotification, McpUiToolCancelledNotificationSchema, @@ -731,6 +737,77 @@ export class App extends Protocol { ); } + /** + * Handler for screenshot requests from the host. + * + * Set this property to register a handler that captures the current visual + * state of the App. The handler should render the App and return the image + * data as a base64-encoded string. + * + * @param callback - Async function that captures the screenshot + * + * @example Capture a screenshot of the App + * ```typescript + * app.onscreenshot = async (params, extra) => { + * const canvas = await html2canvas(document.body); + * const dataUrl = canvas.toDataURL(params.format ?? "image/png", params.quality); + * const [mimeType, data] = dataUrl.replace("data:", "").split(";base64,"); + * return { data, mimeType, width: canvas.width, height: canvas.height }; + * }; + * ``` + * + * @see {@link McpUiScreenshotRequest} for the request structure + * @see {@link McpUiScreenshotResult} for the result structure + */ + set onscreenshot( + callback: ( + params: McpUiScreenshotRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler(McpUiScreenshotRequestSchema, (request, extra) => + callback(request.params, extra), + ); + } + + /** + * Handler for click simulation requests from the host. + * + * Set this property to register a handler that simulates a click at the + * specified coordinates in the App. The handler should dispatch appropriate + * mouse events to the target element. + * + * @param callback - Async function that simulates the click + * + * @example Handle click simulation + * ```typescript + * app.onclick = async ({ x, y, type = "click", button = "left" }, extra) => { + * const element = document.elementFromPoint(x, y); + * if (!element) { + * return { success: false, isError: true }; + * } + * element.dispatchEvent(new MouseEvent(type, { + * clientX: x, clientY: y, bubbles: true, cancelable: true, + * button: button === "left" ? 0 : button === "right" ? 2 : 1 + * })); + * return { success: true, targetElement: element.tagName.toLowerCase() }; + * }; + * ``` + * + * @see {@link McpUiClickRequest} for the request structure + * @see {@link McpUiClickResult} for the result structure + */ + set onclick( + callback: ( + params: McpUiClickRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler(McpUiClickRequestSchema, (request, extra) => + callback(request.params, extra), + ); + } + /** * Convenience handler for tool call requests from the host. * diff --git a/src/generated/schema.json b/src/generated/schema.json index e17767d9..19f31dfa 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -28,6 +28,91 @@ }, "additionalProperties": false }, + "McpUiClickRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/click" + }, + "params": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "X coordinate in pixels, relative to the App's viewport origin." + }, + "y": { + "type": "number", + "description": "Y coordinate in pixels, relative to the App's viewport origin." + }, + "type": { + "description": "Type of click to simulate. Defaults to \"click\" if not specified.", + "anyOf": [ + { + "type": "string", + "const": "click" + }, + { + "type": "string", + "const": "dblclick" + }, + { + "type": "string", + "const": "mousedown" + }, + { + "type": "string", + "const": "mouseup" + } + ] + }, + "button": { + "description": "Mouse button to simulate. Defaults to \"left\" if not specified.", + "anyOf": [ + { + "type": "string", + "const": "left" + }, + { + "type": "string", + "const": "right" + }, + { + "type": "string", + "const": "middle" + } + ] + } + }, + "required": ["x", "y"], + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false + }, + "McpUiClickResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "True if the click was successfully dispatched to the target element." + }, + "targetElement": { + "description": "The element that received the click event, if available.", + "type": "string" + }, + "isError": { + "description": "True if the click dispatch failed.", + "type": "boolean" + } + }, + "required": ["success"], + "additionalProperties": {} + }, "McpUiDisplayMode": { "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ @@ -4053,6 +4138,73 @@ "required": ["method", "params"], "additionalProperties": false }, + "McpUiScreenshotRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/screenshot" + }, + "params": { + "type": "object", + "properties": { + "format": { + "description": "Format for the screenshot image. Defaults to \"png\" if not specified.", + "anyOf": [ + { + "type": "string", + "const": "png" + }, + { + "type": "string", + "const": "jpeg" + }, + { + "type": "string", + "const": "webp" + } + ] + }, + "quality": { + "description": "Quality for lossy formats (jpeg, webp). Value between 0 and 1. Defaults to 0.92 if not specified.", + "type": "number" + } + }, + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false + }, + "McpUiScreenshotResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "Base64-encoded image data." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the image (e.g., \"image/png\", \"image/jpeg\")." + }, + "width": { + "type": "number", + "description": "Width of the captured image in pixels." + }, + "height": { + "type": "number", + "description": "Height of the captured image in pixels." + }, + "isError": { + "description": "True if the screenshot capture failed.", + "type": "boolean" + } + }, + "required": ["data", "mimeType", "width", "height"], + "additionalProperties": {} + }, "McpUiSizeChangedNotification": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 727c28d2..87196678 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -119,6 +119,22 @@ export type McpUiToolMetaSchemaInferredType = z.infer< typeof generated.McpUiToolMetaSchema >; +export type McpUiScreenshotRequestSchemaInferredType = z.infer< + typeof generated.McpUiScreenshotRequestSchema +>; + +export type McpUiScreenshotResultSchemaInferredType = z.infer< + typeof generated.McpUiScreenshotResultSchema +>; + +export type McpUiClickRequestSchemaInferredType = z.infer< + typeof generated.McpUiClickRequestSchema +>; + +export type McpUiClickResultSchemaInferredType = z.infer< + typeof generated.McpUiClickResultSchema +>; + export type McpUiMessageRequestSchemaInferredType = z.infer< typeof generated.McpUiMessageRequestSchema >; @@ -277,6 +293,22 @@ expectType( ); expectType({} as McpUiToolMetaSchemaInferredType); expectType({} as spec.McpUiToolMeta); +expectType( + {} as McpUiScreenshotRequestSchemaInferredType, +); +expectType( + {} as spec.McpUiScreenshotRequest, +); +expectType( + {} as McpUiScreenshotResultSchemaInferredType, +); +expectType( + {} as spec.McpUiScreenshotResult, +); +expectType({} as McpUiClickRequestSchemaInferredType); +expectType({} as spec.McpUiClickRequest); +expectType({} as McpUiClickResultSchemaInferredType); +expectType({} as spec.McpUiClickResult); expectType( {} as McpUiMessageRequestSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 32277d23..f11ed601 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -565,6 +565,122 @@ export const McpUiToolMetaSchema = z.object({ ), }); +/** + * @description Request to capture a screenshot of the App (Host → App). + * The host sends this request to capture the current visual state of the App. + * @see {@link app-bridge!AppBridge.screenshot} for the host method that sends this request + */ +export const McpUiScreenshotRequestSchema = z.object({ + method: z.literal("ui/screenshot"), + params: z.object({ + /** @description Format for the screenshot image. Defaults to "png" if not specified. */ + format: z + .union([z.literal("png"), z.literal("jpeg"), z.literal("webp")]) + .optional() + .describe( + 'Format for the screenshot image. Defaults to "png" if not specified.', + ), + /** @description Quality for lossy formats (jpeg, webp). Value between 0 and 1. Defaults to 0.92 if not specified. */ + quality: z + .number() + .optional() + .describe( + "Quality for lossy formats (jpeg, webp). Value between 0 and 1. Defaults to 0.92 if not specified.", + ), + }), +}); + +/** + * @description Result from a screenshot request. + * @see {@link McpUiScreenshotRequest} + */ +export const McpUiScreenshotResultSchema = z + .object({ + /** @description Base64-encoded image data. */ + data: z.string().describe("Base64-encoded image data."), + /** @description MIME type of the image (e.g., "image/png", "image/jpeg"). */ + mimeType: z + .string() + .describe('MIME type of the image (e.g., "image/png", "image/jpeg").'), + /** @description Width of the captured image in pixels. */ + width: z.number().describe("Width of the captured image in pixels."), + /** @description Height of the captured image in pixels. */ + height: z.number().describe("Height of the captured image in pixels."), + /** @description True if the screenshot capture failed. */ + isError: z + .boolean() + .optional() + .describe("True if the screenshot capture failed."), + }) + .passthrough(); + +/** + * @description Request to simulate a click at a specific position in the App (Host → App). + * The host sends this request to simulate user interaction with the App. + * @see {@link app-bridge!AppBridge.click} for the host method that sends this request + */ +export const McpUiClickRequestSchema = z.object({ + method: z.literal("ui/click"), + params: z.object({ + /** @description X coordinate in pixels, relative to the App's viewport origin. */ + x: z + .number() + .describe( + "X coordinate in pixels, relative to the App's viewport origin.", + ), + /** @description Y coordinate in pixels, relative to the App's viewport origin. */ + y: z + .number() + .describe( + "Y coordinate in pixels, relative to the App's viewport origin.", + ), + /** @description Type of click to simulate. Defaults to "click" if not specified. */ + type: z + .union([ + z.literal("click"), + z.literal("dblclick"), + z.literal("mousedown"), + z.literal("mouseup"), + ]) + .optional() + .describe( + 'Type of click to simulate. Defaults to "click" if not specified.', + ), + /** @description Mouse button to simulate. Defaults to "left" if not specified. */ + button: z + .union([z.literal("left"), z.literal("right"), z.literal("middle")]) + .optional() + .describe( + 'Mouse button to simulate. Defaults to "left" if not specified.', + ), + }), +}); + +/** + * @description Result from a click request. + * @see {@link McpUiClickRequest} + */ +export const McpUiClickResultSchema = z + .object({ + /** @description True if the click was successfully dispatched to the target element. */ + success: z + .boolean() + .describe( + "True if the click was successfully dispatched to the target element.", + ), + /** @description The element that received the click event, if available. */ + targetElement: z + .string() + .optional() + .describe("The element that received the click event, if available."), + /** @description True if the click dispatch failed. */ + isError: z + .boolean() + .optional() + .describe("True if the click dispatch failed."), + }) + .passthrough(); + /** * @description Request to send a message to the host's chat interface. * @see {@link app!App.sendMessage} for the method that sends this request diff --git a/src/spec.types.ts b/src/spec.types.ts index cb6af1f7..9ea0327f 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -630,6 +630,80 @@ export interface McpUiToolMeta { visibility?: McpUiToolVisibility[]; } +/** + * @description Request to capture a screenshot of the App (Host → App). + * The host sends this request to capture the current visual state of the App. + * @see {@link app-bridge!AppBridge.screenshot} for the host method that sends this request + */ +export interface McpUiScreenshotRequest { + method: "ui/screenshot"; + params: { + /** @description Format for the screenshot image. Defaults to "png" if not specified. */ + format?: "png" | "jpeg" | "webp"; + /** @description Quality for lossy formats (jpeg, webp). Value between 0 and 1. Defaults to 0.92 if not specified. */ + quality?: number; + }; +} + +/** + * @description Result from a screenshot request. + * @see {@link McpUiScreenshotRequest} + */ +export interface McpUiScreenshotResult { + /** @description Base64-encoded image data. */ + data: string; + /** @description MIME type of the image (e.g., "image/png", "image/jpeg"). */ + mimeType: string; + /** @description Width of the captured image in pixels. */ + width: number; + /** @description Height of the captured image in pixels. */ + height: number; + /** @description True if the screenshot capture failed. */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The generated schema uses passthrough() to allow additional properties. + */ + [key: string]: unknown; +} + +/** + * @description Request to simulate a click at a specific position in the App (Host → App). + * The host sends this request to simulate user interaction with the App. + * @see {@link app-bridge!AppBridge.click} for the host method that sends this request + */ +export interface McpUiClickRequest { + method: "ui/click"; + params: { + /** @description X coordinate in pixels, relative to the App's viewport origin. */ + x: number; + /** @description Y coordinate in pixels, relative to the App's viewport origin. */ + y: number; + /** @description Type of click to simulate. Defaults to "click" if not specified. */ + type?: "click" | "dblclick" | "mousedown" | "mouseup"; + /** @description Mouse button to simulate. Defaults to "left" if not specified. */ + button?: "left" | "right" | "middle"; + }; +} + +/** + * @description Result from a click request. + * @see {@link McpUiClickRequest} + */ +export interface McpUiClickResult { + /** @description True if the click was successfully dispatched to the target element. */ + success: boolean; + /** @description The element that received the click event, if available. */ + targetElement?: string; + /** @description True if the click dispatch failed. */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The generated schema uses passthrough() to allow additional properties. + */ + [key: string]: unknown; +} + /** * Method string constants for MCP Apps protocol messages. * @@ -672,3 +746,6 @@ export const INITIALIZED_METHOD: McpUiInitializedNotification["method"] = "ui/notifications/initialized"; export const REQUEST_DISPLAY_MODE_METHOD: McpUiRequestDisplayModeRequest["method"] = "ui/request-display-mode"; +export const SCREENSHOT_METHOD: McpUiScreenshotRequest["method"] = + "ui/screenshot"; +export const CLICK_METHOD: McpUiClickRequest["method"] = "ui/click"; diff --git a/src/types.ts b/src/types.ts index 77563dc8..96577af2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,8 @@ export { INITIALIZE_METHOD, INITIALIZED_METHOD, REQUEST_DISPLAY_MODE_METHOD, + SCREENSHOT_METHOD, + CLICK_METHOD, type McpUiTheme, type McpUiDisplayMode, type McpUiStyleVariableKey, @@ -59,6 +61,10 @@ export { type McpUiResourceMeta, type McpUiRequestDisplayModeRequest, type McpUiRequestDisplayModeResult, + type McpUiScreenshotRequest, + type McpUiScreenshotResult, + type McpUiClickRequest, + type McpUiClickResult, type McpUiToolVisibility, type McpUiToolMeta, } from "./spec.types.js"; @@ -71,6 +77,8 @@ import type { McpUiUpdateModelContextRequest, McpUiResourceTeardownRequest, McpUiRequestDisplayModeRequest, + McpUiScreenshotRequest, + McpUiClickRequest, McpUiHostContextChangedNotification, McpUiToolInputNotification, McpUiToolInputPartialNotification, @@ -120,6 +128,10 @@ export { McpUiResourceMetaSchema, McpUiRequestDisplayModeRequestSchema, McpUiRequestDisplayModeResultSchema, + McpUiScreenshotRequestSchema, + McpUiScreenshotResultSchema, + McpUiClickRequestSchema, + McpUiClickResultSchema, McpUiToolVisibilitySchema, McpUiToolMetaSchema, } from "./generated/schema.js"; @@ -162,6 +174,8 @@ export type AppRequest = | McpUiUpdateModelContextRequest | McpUiResourceTeardownRequest | McpUiRequestDisplayModeRequest + | McpUiScreenshotRequest + | McpUiClickRequest | CallToolRequest | ListToolsRequest | ListResourcesRequest From e18d51bca0607379f3bd8efa8970786ee45d5559 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sat, 17 Jan 2026 03:07:14 +0000 Subject: [PATCH 12/17] feat(map-server): Enable registerTool with navigate-to and get-current-view - Uncomment and fix the navigate-to tool for animated navigation - Add get-current-view tool to query camera position and bounding box - Add flyToBoundingBox function for smooth camera animation - Add setLabel function for displaying location labels --- examples/map-server/src/mcp-app.ts | 226 ++++++++++++++++++++++------- 1 file changed, 175 insertions(+), 51 deletions(-) diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 891ef2c4..bccebe07 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -6,6 +6,7 @@ * a navigate-to tool for the host to control navigation. */ import { App } from "@modelcontextprotocol/ext-apps"; +import { z } from "zod"; // TypeScript declaration for Cesium loaded from CDN // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -654,6 +655,76 @@ function setViewToBoundingBox(cesiumViewer: any, bbox: BoundingBox): void { ); } +/** + * Fly camera to view a bounding box with animation + */ +function flyToBoundingBox( + cesiumViewer: any, + bbox: BoundingBox, + duration = 2, +): Promise { + return new Promise((resolve) => { + const { destination, centerLon, centerLat, height } = + calculateDestination(bbox); + + log.info("flyTo destination:", centerLon, centerLat, "height:", height); + + cesiumViewer.camera.flyTo({ + destination, + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(-90), // Look straight down + roll: 0, + }, + duration, + complete: () => { + log.info( + "flyTo complete, camera height:", + cesiumViewer.camera.positionCartographic.height, + ); + resolve(); + }, + cancel: () => { + log.warn("flyTo cancelled"); + resolve(); + }, + }); + }); +} + +// Label element for displaying location info +let labelElement: HTMLDivElement | null = null; + +/** + * Set or clear the label displayed on the map + */ +function setLabel(text?: string): void { + if (!labelElement) { + labelElement = document.createElement("div"); + labelElement.style.cssText = ` + position: absolute; + top: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-family: sans-serif; + font-size: 14px; + z-index: 100; + pointer-events: none; + `; + document.body.appendChild(labelElement); + } + + if (text) { + labelElement.textContent = text; + labelElement.style.display = "block"; + } else { + labelElement.style.display = "none"; + } +} + /** * Wait for globe tiles to finish loading */ @@ -886,57 +957,110 @@ app.ontoolinput = async (params) => { } }; -/* - Register tools for the model to interact w/ this component - Needs https://github.com/modelcontextprotocol/ext-apps/pull/72 -*/ -// app.registerTool( -// "navigate-to", -// { -// title: "Navigate To", -// description: "Navigate the globe to a new bounding box location", -// inputSchema: z.object({ -// west: z.number().describe("Western longitude (-180 to 180)"), -// south: z.number().describe("Southern latitude (-90 to 90)"), -// east: z.number().describe("Eastern longitude (-180 to 180)"), -// north: z.number().describe("Northern latitude (-90 to 90)"), -// duration: z -// .number() -// .optional() -// .describe("Animation duration in seconds (default: 2)"), -// label: z.string().optional().describe("Optional label to display"), -// }), -// }, -// async (args) => { -// if (!viewer) { -// return { -// content: [ -// { type: "text" as const, text: "Error: Viewer not initialized" }, -// ], -// isError: true, -// }; -// } - -// const bbox: BoundingBox = { -// west: args.west, -// south: args.south, -// east: args.east, -// north: args.north, -// }; - -// await flyToBoundingBox(viewer, bbox, args.duration ?? 2); -// setLabel(args.label); - -// return { -// content: [ -// { -// type: "text" as const, -// text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`, -// }, -// ], -// }; -// }, -// ); +// Register tools for the model to interact with this component +app.registerTool( + "navigate-to", + { + title: "Navigate To", + description: "Navigate the globe to a new bounding box location", + inputSchema: z.object({ + west: z.number().describe("Western longitude (-180 to 180)"), + south: z.number().describe("Southern latitude (-90 to 90)"), + east: z.number().describe("Eastern longitude (-180 to 180)"), + north: z.number().describe("Northern latitude (-90 to 90)"), + duration: z + .number() + .optional() + .describe("Animation duration in seconds (default: 2)"), + label: z.string().optional().describe("Optional label to display"), + }), + }, + async (args) => { + if (!viewer) { + return { + content: [ + { type: "text" as const, text: "Error: Viewer not initialized" }, + ], + isError: true, + }; + } + + const bbox: BoundingBox = { + west: args.west, + south: args.south, + east: args.east, + north: args.north, + }; + + await flyToBoundingBox(viewer, bbox, args.duration ?? 2); + setLabel(args.label); + + return { + content: [ + { + type: "text" as const, + text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`, + }, + ], + }; + }, +); + +app.registerTool( + "get-current-view", + { + title: "Get Current View", + description: + "Get the current camera position and bounding box visible on the globe", + }, + async () => { + if (!viewer) { + return { + content: [ + { type: "text" as const, text: "Error: Viewer not initialized" }, + ], + isError: true, + }; + } + + const camera = viewer.camera; + const positionCartographic = camera.positionCartographic; + const latitude = Cesium.Math.toDegrees(positionCartographic.latitude); + const longitude = Cesium.Math.toDegrees(positionCartographic.longitude); + const height = positionCartographic.height; + + // Get the visible bounding box + const rectangle = viewer.camera.computeViewRectangle(); + let bbox = null; + if (rectangle) { + bbox = { + west: Cesium.Math.toDegrees(rectangle.west), + south: Cesium.Math.toDegrees(rectangle.south), + east: Cesium.Math.toDegrees(rectangle.east), + north: Cesium.Math.toDegrees(rectangle.north), + }; + } + + const viewData = { + camera: { + latitude, + longitude, + height, + }, + bbox, + }; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(viewData, null, 2), + }, + ], + structuredContent: viewData, + }; + }, +); // Handle tool result - extract widgetUUID and restore persisted view if available app.ontoolresult = async (result) => { From c9f8c8e42b78651b48990284b7405295a90ff0c7 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sat, 17 Jan 2026 03:08:48 +0000 Subject: [PATCH 13/17] feat(pdf-server): Add widget interaction tools - get-document-info: Get title, current page, total pages, zoom level - go-to-page: Navigate to a specific page - get-page-text: Extract text from a page - search-text: Search for text across the document - set-zoom: Adjust zoom level --- examples/pdf-server/src/mcp-app.ts | 249 +++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index b5b24194..04760690 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -8,6 +8,7 @@ */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; import * as pdfjsLib from "pdfjs-dist"; import { TextLayer } from "pdfjs-dist"; import "./global.css"; @@ -786,6 +787,254 @@ function handleHostContextChanged(ctx: McpUiHostContext) { app.onhostcontextchanged = handleHostContextChanged; +// Register tools for model interaction +app.registerTool( + "get-document-info", + { + title: "Get Document Info", + description: + "Get information about the current PDF document including title, current page, total pages, and zoom level", + }, + async () => { + if (!pdfDocument) { + return { + content: [ + { type: "text" as const, text: "Error: No document loaded" }, + ], + isError: true, + }; + } + const info = { + title: pdfTitle || "Untitled", + url: pdfUrl, + currentPage, + totalPages, + scale, + displayMode: currentDisplayMode, + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(info, null, 2) }], + structuredContent: info, + }; + }, +); + +app.registerTool( + "go-to-page", + { + title: "Go to Page", + description: "Navigate to a specific page in the document", + inputSchema: z.object({ + page: z.number().int().positive().describe("Page number (1-indexed)"), + }), + }, + async (args) => { + if (!pdfDocument) { + return { + content: [ + { type: "text" as const, text: "Error: No document loaded" }, + ], + isError: true, + }; + } + if (args.page < 1 || args.page > totalPages) { + return { + content: [ + { + type: "text" as const, + text: `Error: Page ${args.page} out of range (1-${totalPages})`, + }, + ], + isError: true, + }; + } + currentPage = args.page; + await renderPage(); + updateControls(); + return { + content: [ + { + type: "text" as const, + text: `Navigated to page ${currentPage}/${totalPages}`, + }, + ], + }; + }, +); + +app.registerTool( + "get-page-text", + { + title: "Get Page Text", + description: "Extract text content from a specific page", + inputSchema: z.object({ + page: z + .number() + .int() + .positive() + .optional() + .describe("Page number (1-indexed). Defaults to current page."), + }), + }, + async (args) => { + if (!pdfDocument) { + return { + content: [ + { type: "text" as const, text: "Error: No document loaded" }, + ], + isError: true, + }; + } + const pageNum = args.page ?? currentPage; + if (pageNum < 1 || pageNum > totalPages) { + return { + content: [ + { + type: "text" as const, + text: `Error: Page ${pageNum} out of range (1-${totalPages})`, + }, + ], + isError: true, + }; + } + try { + const page = await pdfDocument.getPage(pageNum); + const textContent = await page.getTextContent(); + const pageText = (textContent.items as Array<{ str?: string }>) + .map((item) => item.str || "") + .join(""); + return { + content: [{ type: "text" as const, text: pageText }], + structuredContent: { page: pageNum, text: pageText }, + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Error extracting text: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, +); + +app.registerTool( + "search-text", + { + title: "Search Text", + description: "Search for text in the document and return matching pages", + inputSchema: z.object({ + query: z.string().describe("Text to search for"), + maxResults: z + .number() + .int() + .positive() + .optional() + .describe("Maximum number of results to return (default: 10)"), + }), + }, + async (args) => { + if (!pdfDocument) { + return { + content: [ + { type: "text" as const, text: "Error: No document loaded" }, + ], + isError: true, + }; + } + const maxResults = args.maxResults ?? 10; + const results: Array<{ page: number; context: string }> = []; + const query = args.query.toLowerCase(); + + for (let i = 1; i <= totalPages && results.length < maxResults; i++) { + try { + const page = await pdfDocument.getPage(i); + const textContent = await page.getTextContent(); + const pageText = (textContent.items as Array<{ str?: string }>) + .map((item) => item.str || "") + .join(""); + + const lowerText = pageText.toLowerCase(); + const index = lowerText.indexOf(query); + if (index !== -1) { + // Extract context around the match + const start = Math.max(0, index - 50); + const end = Math.min(pageText.length, index + query.length + 50); + const context = pageText.slice(start, end); + results.push({ page: i, context: `...${context}...` }); + } + } catch (err) { + log.error(`Error searching page ${i}:`, err); + } + } + + if (results.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No matches found for "${args.query}"`, + }, + ], + structuredContent: { query: args.query, results: [] }, + }; + } + + const summary = results + .map((r) => `Page ${r.page}: ${r.context}`) + .join("\n\n"); + return { + content: [ + { + type: "text" as const, + text: `Found ${results.length} match(es) for "${args.query}":\n\n${summary}`, + }, + ], + structuredContent: { query: args.query, results }, + }; + }, +); + +app.registerTool( + "set-zoom", + { + title: "Set Zoom", + description: "Set the zoom level for the document", + inputSchema: z.object({ + scale: z + .number() + .min(0.25) + .max(4) + .describe("Zoom scale (0.25 to 4, where 1 = 100%)"), + }), + }, + async (args) => { + if (!pdfDocument) { + return { + content: [ + { type: "text" as const, text: "Error: No document loaded" }, + ], + isError: true, + }; + } + scale = args.scale; + await renderPage(); + zoomLevelEl.textContent = `${Math.round(scale * 100)}%`; + requestFitToContent(); + return { + content: [ + { + type: "text" as const, + text: `Zoom set to ${Math.round(scale * 100)}%`, + }, + ], + }; + }, +); + // Connect to host app.connect().then(() => { log.info("Connected to host"); From 3e6545a4f15d488ac71bf82733b1a81a7dff95aa Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sat, 17 Jan 2026 03:10:55 +0000 Subject: [PATCH 14/17] feat(shadertoy-server): Add widget interaction tools --- .../budget-allocator-server/src/mcp-app.ts | 289 ++++++++++++++++++ examples/shadertoy-server/src/mcp-app.ts | 164 +++++++++- examples/wiki-explorer-server/src/mcp-app.ts | 217 +++++++++++++ 3 files changed, 660 insertions(+), 10 deletions(-) diff --git a/examples/budget-allocator-server/src/mcp-app.ts b/examples/budget-allocator-server/src/mcp-app.ts index 4e8c62df..d878a636 100644 --- a/examples/budget-allocator-server/src/mcp-app.ts +++ b/examples/budget-allocator-server/src/mcp-app.ts @@ -3,6 +3,7 @@ */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { Chart, registerables } from "chart.js"; +import { z } from "zod"; import "./global.css"; import "./mcp-app.css"; @@ -626,6 +627,294 @@ function handleHostContextChanged(ctx: McpUiHostContext) { app.onhostcontextchanged = handleHostContextChanged; +// Register tools for model interaction +app.registerTool( + "get-allocations", + { + title: "Get Budget Allocations", + description: + "Get the current budget allocations including total budget, percentages, and amounts per category", + }, + async () => { + if (!state.config) { + return { + content: [ + { type: "text" as const, text: "Error: Configuration not loaded" }, + ], + isError: true, + }; + } + + const allocations: Record = {}; + for (const category of state.config.categories) { + const percent = state.allocations.get(category.id) ?? 0; + allocations[category.id] = { + percent, + amount: (percent / 100) * state.totalBudget, + }; + } + + const result = { + totalBudget: state.totalBudget, + currency: state.config.currency, + currencySymbol: state.config.currencySymbol, + selectedStage: state.selectedStage, + allocations, + categories: state.config.categories.map((c) => ({ + id: c.id, + name: c.name, + color: c.color, + })), + }; + + return { + content: [ + { type: "text" as const, text: JSON.stringify(result, null, 2) }, + ], + structuredContent: result, + }; + }, +); + +app.registerTool( + "set-allocation", + { + title: "Set Category Allocation", + description: + "Set the allocation percentage for a specific budget category", + inputSchema: z.object({ + categoryId: z + .string() + .describe("Category ID (e.g., 'rd', 'sales', 'marketing', 'ops', 'ga')"), + percent: z + .number() + .min(0) + .max(100) + .describe("Allocation percentage (0-100)"), + }), + }, + async (args) => { + if (!state.config) { + return { + content: [ + { type: "text" as const, text: "Error: Configuration not loaded" }, + ], + isError: true, + }; + } + + const category = state.config.categories.find((c) => c.id === args.categoryId); + if (!category) { + return { + content: [ + { + type: "text" as const, + text: `Error: Category "${args.categoryId}" not found. Available: ${state.config.categories.map((c) => c.id).join(", ")}`, + }, + ], + isError: true, + }; + } + + handleSliderChange(args.categoryId, args.percent); + + // Also update the slider UI + const slider = document.querySelector( + `.slider-row[data-category-id="${args.categoryId}"] .slider`, + ) as HTMLInputElement | null; + if (slider) { + slider.value = String(args.percent); + } + + const amount = (args.percent / 100) * state.totalBudget; + return { + content: [ + { + type: "text" as const, + text: `Set ${category.name} allocation to ${args.percent.toFixed(1)}% (${state.config.currencySymbol}${amount.toLocaleString()})`, + }, + ], + }; + }, +); + +app.registerTool( + "set-total-budget", + { + title: "Set Total Budget", + description: "Set the total budget amount", + inputSchema: z.object({ + amount: z.number().positive().describe("Total budget amount"), + }), + }, + async (args) => { + if (!state.config) { + return { + content: [ + { type: "text" as const, text: "Error: Configuration not loaded" }, + ], + isError: true, + }; + } + + state.totalBudget = args.amount; + + // Update the budget selector if this amount is a preset + const budgetSelector = document.getElementById( + "budget-selector", + ) as HTMLSelectElement | null; + if (budgetSelector) { + const option = Array.from(budgetSelector.options).find( + (opt) => parseInt(opt.value) === args.amount, + ); + if (option) { + budgetSelector.value = String(args.amount); + } + } + + updateAllSliderAmounts(); + updateStatusBar(); + updateComparisonSummary(); + + return { + content: [ + { + type: "text" as const, + text: `Total budget set to ${state.config.currencySymbol}${args.amount.toLocaleString()}`, + }, + ], + }; + }, +); + +app.registerTool( + "set-company-stage", + { + title: "Set Company Stage", + description: + "Set the company stage for benchmark comparison (seed, series_a, series_b, growth)", + inputSchema: z.object({ + stage: z.string().describe("Company stage ID"), + }), + }, + async (args) => { + if (!state.analytics) { + return { + content: [ + { type: "text" as const, text: "Error: Analytics not loaded" }, + ], + isError: true, + }; + } + + if (!state.analytics.stages.includes(args.stage)) { + return { + content: [ + { + type: "text" as const, + text: `Error: Stage "${args.stage}" not found. Available: ${state.analytics.stages.join(", ")}`, + }, + ], + isError: true, + }; + } + + state.selectedStage = args.stage; + + // Update the stage selector UI + const stageSelector = document.getElementById( + "stage-selector", + ) as HTMLSelectElement | null; + if (stageSelector) { + stageSelector.value = args.stage; + } + + // Update all badges and summary + if (state.config) { + for (const category of state.config.categories) { + updatePercentileBadge(category.id); + } + updateComparisonSummary(); + } + + return { + content: [ + { + type: "text" as const, + text: `Company stage set to "${args.stage}"`, + }, + ], + }; + }, +); + +app.registerTool( + "get-benchmark-comparison", + { + title: "Get Benchmark Comparison", + description: + "Compare current allocations against industry benchmarks for the selected stage", + }, + async () => { + if (!state.config || !state.analytics) { + return { + content: [ + { type: "text" as const, text: "Error: Data not loaded" }, + ], + isError: true, + }; + } + + const benchmark = state.analytics.benchmarks.find( + (b) => b.stage === state.selectedStage, + ); + if (!benchmark) { + return { + content: [ + { + type: "text" as const, + text: `Error: No benchmark data for stage "${state.selectedStage}"`, + }, + ], + isError: true, + }; + } + + const comparison: Record< + string, + { current: number; p25: number; p50: number; p75: number; status: string } + > = {}; + + for (const category of state.config.categories) { + const current = state.allocations.get(category.id) ?? 0; + const benchmarkData = benchmark.categoryBenchmarks[category.id]; + let status = "within range"; + if (current < benchmarkData.p25) status = "below p25"; + else if (current > benchmarkData.p75) status = "above p75"; + + comparison[category.id] = { + current, + p25: benchmarkData.p25, + p50: benchmarkData.p50, + p75: benchmarkData.p75, + status, + }; + } + + const result = { + stage: state.selectedStage, + comparison, + }; + + return { + content: [ + { type: "text" as const, text: JSON.stringify(result, null, 2) }, + ], + structuredContent: result, + }; + }, +); + // Handle theme changes window .matchMedia("(prefers-color-scheme: dark)") diff --git a/examples/shadertoy-server/src/mcp-app.ts b/examples/shadertoy-server/src/mcp-app.ts index 2dd7d418..c88531af 100644 --- a/examples/shadertoy-server/src/mcp-app.ts +++ b/examples/shadertoy-server/src/mcp-app.ts @@ -2,6 +2,7 @@ * ShaderToy renderer MCP App using ShaderToyLite.js */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import { z } from "zod"; import "./global.css"; import "./mcp-app.css"; import ShaderToyLite, { @@ -101,6 +102,39 @@ fullscreenBtn.addEventListener("click", toggleFullscreen); // ShaderToyLite instance let shaderToy: ShaderToyLiteInstance | null = null; +// Track current shader sources +let currentShaderSources: ShaderInput = { + fragmentShader: "", +}; + +// Track compilation status +interface CompilationStatus { + success: boolean; + errors: string[]; + timestamp: number; +} +let lastCompilationStatus: CompilationStatus = { + success: true, + errors: [], + timestamp: Date.now(), +}; + +// Intercept console.error to capture shader compilation errors +const originalConsoleError = console.error.bind(console); +const capturedErrors: string[] = []; +console.error = (...args: unknown[]) => { + originalConsoleError(...args); + // Capture shader compilation errors + const message = args.map((arg) => String(arg)).join(" "); + if ( + message.includes("Shader compilation failed") || + message.includes("Program initialization failed") || + message.includes("Failed to compile") + ) { + capturedErrors.push(message); + } +}; + // Create app instance const app = new App({ name: "ShaderToy Renderer", version: "1.0.0" }); @@ -112,22 +146,18 @@ app.onteardown = async () => { return {}; }; -app.ontoolinput = (params) => { - log.info("Received shader input"); - - if (!isShaderInput(params.arguments)) { - log.error("Invalid tool input"); - return; - } - - const { fragmentShader, common, bufferA, bufferB, bufferC, bufferD } = - params.arguments; +// Helper function to compile shader and update status +function compileAndUpdateStatus(input: ShaderInput): void { + // Clear captured errors before compilation + capturedErrors.length = 0; // Initialize ShaderToyLite if needed if (!shaderToy) { shaderToy = new ShaderToyLite("canvas"); } + const { fragmentShader, common, bufferA, bufferB, bufferC, bufferD } = input; + // Set common code (shared across all shaders) shaderToy.setCommon(common || ""); @@ -155,6 +185,45 @@ app.ontoolinput = (params) => { }); shaderToy.play(); + + // Update compilation status + const hasErrors = capturedErrors.length > 0; + lastCompilationStatus = { + success: !hasErrors, + errors: [...capturedErrors], + timestamp: Date.now(), + }; + + // Store current sources + currentShaderSources = { ...input }; + + // Send compilation status to model context if there are errors + if (hasErrors) { + app + .updateModelContext({ + content: [ + { + type: "text", + text: `Shader compilation failed:\n${capturedErrors.join("\n")}`, + }, + ], + structuredContent: { + compilationStatus: lastCompilationStatus, + }, + }) + .catch((err) => log.error("Failed to update model context:", err)); + } +} + +app.ontoolinput = (params) => { + log.info("Received shader input"); + + if (!isShaderInput(params.arguments)) { + log.error("Invalid tool input"); + return; + } + + compileAndUpdateStatus(params.arguments); log.info("Setup complete"); }; @@ -162,6 +231,81 @@ app.onerror = log.error; app.onhostcontextchanged = handleHostContextChanged; +// Register tool: set-shader-source +app.registerTool( + "set-shader-source", + { + title: "Set Shader Source", + description: + "Update the shader source code. Compiles and runs the new shader immediately.", + inputSchema: z.object({ + fragmentShader: z.string().describe("The main fragment shader source code (mainImage function)"), + common: z.string().optional().describe("Common code shared across all shaders"), + bufferA: z.string().optional().describe("Buffer A shader source (for multi-pass rendering)"), + bufferB: z.string().optional().describe("Buffer B shader source (for multi-pass rendering)"), + bufferC: z.string().optional().describe("Buffer C shader source (for multi-pass rendering)"), + bufferD: z.string().optional().describe("Buffer D shader source (for multi-pass rendering)"), + }), + }, + async (args) => { + log.info("set-shader-source tool called"); + + compileAndUpdateStatus(args); + + const result = lastCompilationStatus.success + ? "Shader compiled and running successfully." + : `Shader compilation failed:\n${lastCompilationStatus.errors.join("\n")}`; + + return { + content: [{ type: "text" as const, text: result }], + structuredContent: { + success: lastCompilationStatus.success, + errors: lastCompilationStatus.errors, + timestamp: lastCompilationStatus.timestamp, + }, + }; + }, +); + +// Register tool: get-shader-info +app.registerTool( + "get-shader-info", + { + title: "Get Shader Info", + description: + "Get the current shader source code and compilation status.", + }, + async () => { + log.info("get-shader-info tool called"); + + const hasShader = currentShaderSources.fragmentShader.length > 0; + const isPlaying = shaderToy?.isPlaying() ?? false; + + let statusText = ""; + if (!hasShader) { + statusText = "No shader loaded."; + } else if (lastCompilationStatus.success) { + statusText = `Shader is ${isPlaying ? "running" : "paused"}.`; + } else { + statusText = `Shader has compilation errors:\n${lastCompilationStatus.errors.join("\n")}`; + } + + return { + content: [ + { + type: "text" as const, + text: `${statusText}\n\nCurrent fragment shader:\n${currentShaderSources.fragmentShader || "(none)"}`, + }, + ], + structuredContent: { + sources: currentShaderSources, + compilationStatus: lastCompilationStatus, + isPlaying, + }, + }; + }, +); + // Connect to host app.connect().then(() => { log.info("Connected to host"); diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index 7d95cb8d..5c9acfd5 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -3,6 +3,7 @@ */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; import { forceCenter, forceCollide, @@ -385,6 +386,222 @@ function handleHostContextChanged(ctx: McpUiHostContext) { app.onhostcontextchanged = handleHostContextChanged; +// ============================================================================= +// Widget Interaction Tools +// ============================================================================= + +// Tool: Search for a Wikipedia article and navigate to it +app.registerTool( + "search-article", + { + title: "Search Article", + description: + "Search for a Wikipedia article and add it to the graph as the new starting point", + inputSchema: z.object({ + query: z.string().describe("Search query for Wikipedia article"), + }), + }, + async (args) => { + const { query } = args as { query: string }; + + // Construct Wikipedia search URL that redirects to the article + const searchUrl = `https://en.wikipedia.org/wiki/Special:Search?go=Go&search=${encodeURIComponent(query)}`; + + // Use the server tool to fetch the article + const result = await app.callServerTool({ + name: "get-first-degree-links", + arguments: { url: searchUrl }, + }); + + // Clear existing graph and start fresh with this article + graphData.nodes = []; + graphData.links = []; + + const response = result.structuredContent as unknown as ToolResponse; + if (response && response.page) { + initialUrl = response.page.url; + addNode(response.page.url, response.page.title, "default", { x: 0, y: 0 }); + graph.warmupTicks(100); + handleToolResultData(result); + graph.centerAt(0, 0, 500); + + return { + content: [ + { + type: "text" as const, + text: `Navigated to article: ${response.page.title}`, + }, + ], + structuredContent: { + success: true, + article: response.page, + linksFound: response.links?.length ?? 0, + }, + }; + } + + return { + content: [ + { type: "text" as const, text: `Could not find article for: ${query}` }, + ], + structuredContent: { + success: false, + error: "Article not found", + }, + }; + }, +); + +// Tool: Get information about the currently displayed article +app.registerTool( + "get-current-article", + { + title: "Get Current Article", + description: + "Get information about the currently selected or initial article in the graph", + }, + async () => { + const currentUrl = selectedNodeUrl || initialUrl; + + if (!currentUrl) { + return { + content: [{ type: "text" as const, text: "No article is currently selected" }], + structuredContent: { + hasSelection: false, + article: null, + }, + }; + } + + const node = graphData.nodes.find((n) => n.url === currentUrl); + + if (!node) { + return { + content: [{ type: "text" as const, text: "Selected article not found in graph" }], + structuredContent: { + hasSelection: false, + article: null, + }, + }; + } + + return { + content: [ + { + type: "text" as const, + text: `Current article: ${node.title}\nURL: ${node.url}\nState: ${node.state}`, + }, + ], + structuredContent: { + hasSelection: true, + article: { + url: node.url, + title: node.title, + state: node.state, + isExpanded: node.state === "expanded", + hasError: node.state === "error", + errorMessage: node.errorMessage, + }, + }, + }; + }, +); + +// Tool: Highlight a specific node in the graph +app.registerTool( + "highlight-node", + { + title: "Highlight Node", + description: "Highlight and center on a specific node in the graph by title or URL", + inputSchema: z.object({ + identifier: z + .string() + .describe("The title or URL of the node to highlight"), + }), + }, + async (args) => { + const { identifier } = args as { identifier: string }; + const lowerIdentifier = identifier.toLowerCase(); + + // Find node by title (case-insensitive partial match) or exact URL + const node = graphData.nodes.find( + (n) => + n.url === identifier || + n.title.toLowerCase().includes(lowerIdentifier), + ); + + if (!node) { + return { + content: [ + { type: "text" as const, text: `Node not found: ${identifier}` }, + ], + structuredContent: { + success: false, + error: "Node not found in graph", + availableNodes: graphData.nodes.map((n) => n.title), + }, + }; + } + + // Center on the node and select it + selectedNodeUrl = node.url; + if (node.x !== undefined && node.y !== undefined) { + graph.centerAt(node.x, node.y, 500); + graph.zoom(2, 500); + } + + return { + content: [ + { type: "text" as const, text: `Highlighted node: ${node.title}` }, + ], + structuredContent: { + success: true, + node: { + url: node.url, + title: node.title, + state: node.state, + }, + }, + }; + }, +); + +// Tool: Get list of currently visible nodes in the graph +app.registerTool( + "get-visible-nodes", + { + title: "Get Visible Nodes", + description: "Get a list of all nodes currently visible in the graph", + }, + async () => { + const nodes = graphData.nodes.map((n) => ({ + url: n.url, + title: n.title, + state: n.state, + isExpanded: n.state === "expanded", + hasError: n.state === "error", + })); + + const expandedCount = nodes.filter((n) => n.isExpanded).length; + const errorCount = nodes.filter((n) => n.hasError).length; + + return { + content: [ + { + type: "text" as const, + text: `Graph contains ${nodes.length} nodes:\n${nodes.map((n) => `- ${n.title} (${n.state})`).join("\n")}`, + }, + ], + structuredContent: { + totalNodes: nodes.length, + expandedNodes: expandedCount, + errorNodes: errorCount, + nodes, + }, + }; + }, +); + // Connect to host app.connect().then(() => { const ctx = app.getHostContext(); From 8f592a354a9c41199b3cd3d30c1e9c311147585f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sat, 17 Jan 2026 03:12:44 +0000 Subject: [PATCH 15/17] feat(examples): Add widget interaction tools to budget-allocator, shadertoy, wiki-explorer, and threejs budget-allocator: - get-allocations: Get current budget allocations - set-allocation: Set allocation for a category - set-total-budget: Adjust total budget - set-company-stage: Change stage for benchmarks - get-benchmark-comparison: Compare against benchmarks shadertoy: - set-shader-source: Update shader source code - get-shader-info: Get shader source and compilation status - Sends errors via updateModelContext wiki-explorer: - search-article: Search for Wikipedia articles - get-current-article: Get current article info - highlight-node: Highlight a graph node - get-visible-nodes: List visible nodes threejs: - set-scene-source: Update the Three.js scene source code - get-scene-info: Get current scene state and any errors - Sends syntax errors to model via updateModelContext --- .../budget-allocator-server/src/mcp-app.ts | 15 +- examples/shadertoy-server/src/mcp-app.ts | 32 +++- .../threejs-server/src/mcp-app-wrapper.tsx | 164 +++++++++++++++++- examples/threejs-server/src/threejs-app.tsx | 20 ++- examples/wiki-explorer-server/src/mcp-app.ts | 22 ++- 5 files changed, 225 insertions(+), 28 deletions(-) diff --git a/examples/budget-allocator-server/src/mcp-app.ts b/examples/budget-allocator-server/src/mcp-app.ts index d878a636..c5d8cb1d 100644 --- a/examples/budget-allocator-server/src/mcp-app.ts +++ b/examples/budget-allocator-server/src/mcp-app.ts @@ -680,12 +680,13 @@ app.registerTool( "set-allocation", { title: "Set Category Allocation", - description: - "Set the allocation percentage for a specific budget category", + description: "Set the allocation percentage for a specific budget category", inputSchema: z.object({ categoryId: z .string() - .describe("Category ID (e.g., 'rd', 'sales', 'marketing', 'ops', 'ga')"), + .describe( + "Category ID (e.g., 'rd', 'sales', 'marketing', 'ops', 'ga')", + ), percent: z .number() .min(0) @@ -703,7 +704,9 @@ app.registerTool( }; } - const category = state.config.categories.find((c) => c.id === args.categoryId); + const category = state.config.categories.find( + (c) => c.id === args.categoryId, + ); if (!category) { return { content: [ @@ -858,9 +861,7 @@ app.registerTool( async () => { if (!state.config || !state.analytics) { return { - content: [ - { type: "text" as const, text: "Error: Data not loaded" }, - ], + content: [{ type: "text" as const, text: "Error: Data not loaded" }], isError: true, }; } diff --git a/examples/shadertoy-server/src/mcp-app.ts b/examples/shadertoy-server/src/mcp-app.ts index c88531af..e30cf952 100644 --- a/examples/shadertoy-server/src/mcp-app.ts +++ b/examples/shadertoy-server/src/mcp-app.ts @@ -239,12 +239,29 @@ app.registerTool( description: "Update the shader source code. Compiles and runs the new shader immediately.", inputSchema: z.object({ - fragmentShader: z.string().describe("The main fragment shader source code (mainImage function)"), - common: z.string().optional().describe("Common code shared across all shaders"), - bufferA: z.string().optional().describe("Buffer A shader source (for multi-pass rendering)"), - bufferB: z.string().optional().describe("Buffer B shader source (for multi-pass rendering)"), - bufferC: z.string().optional().describe("Buffer C shader source (for multi-pass rendering)"), - bufferD: z.string().optional().describe("Buffer D shader source (for multi-pass rendering)"), + fragmentShader: z + .string() + .describe("The main fragment shader source code (mainImage function)"), + common: z + .string() + .optional() + .describe("Common code shared across all shaders"), + bufferA: z + .string() + .optional() + .describe("Buffer A shader source (for multi-pass rendering)"), + bufferB: z + .string() + .optional() + .describe("Buffer B shader source (for multi-pass rendering)"), + bufferC: z + .string() + .optional() + .describe("Buffer C shader source (for multi-pass rendering)"), + bufferD: z + .string() + .optional() + .describe("Buffer D shader source (for multi-pass rendering)"), }), }, async (args) => { @@ -272,8 +289,7 @@ app.registerTool( "get-shader-info", { title: "Get Shader Info", - description: - "Get the current shader source code and compilation status.", + description: "Get the current shader source code and compilation status.", }, async () => { log.info("get-shader-info tool called"); diff --git a/examples/threejs-server/src/mcp-app-wrapper.tsx b/examples/threejs-server/src/mcp-app-wrapper.tsx index fe94721d..ae900db8 100644 --- a/examples/threejs-server/src/mcp-app-wrapper.tsx +++ b/examples/threejs-server/src/mcp-app-wrapper.tsx @@ -7,8 +7,9 @@ import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { StrictMode, useState, useCallback, useEffect } from "react"; +import { StrictMode, useState, useCallback, useEffect, useRef } from "react"; import { createRoot } from "react-dom/client"; +import { z } from "zod"; import ThreeJSApp from "./threejs-app.tsx"; import "./global.css"; @@ -16,6 +17,20 @@ import "./global.css"; // Types // ============================================================================= +/** + * Scene state tracked for widget interaction tools. + */ +export interface SceneState { + /** Current Three.js code */ + code: string | null; + /** Canvas height */ + height: number; + /** Last error message if any */ + error: string | null; + /** Whether the scene is currently rendering */ + isRendering: boolean; +} + /** * Props passed to the widget component. * This interface can be reused for other widgets. @@ -37,6 +52,96 @@ export interface WidgetProps> { openLink: App["openLink"]; /** Send log messages to the host */ sendLog: App["sendLog"]; + /** Callback to report scene errors */ + onSceneError: (error: string | null) => void; + /** Callback to report scene is rendering */ + onSceneRendering: (isRendering: boolean) => void; +} + +// ============================================================================= +// Widget Interaction Tools +// ============================================================================= + +/** + * Registers widget interaction tools on the App instance. + * These tools allow the model to interact with the Three.js scene. + */ +function registerWidgetTools( + app: App, + sceneStateRef: React.RefObject, +): void { + // Tool: set-scene-source - Update the scene source/configuration + app.registerTool( + "set-scene-source", + { + title: "Set Scene Source", + description: + "Update the Three.js scene source code. The code will be executed in a sandboxed environment with access to THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, and height.", + inputSchema: z.object({ + code: z.string().describe("JavaScript code to render the 3D scene"), + height: z + .number() + .int() + .positive() + .optional() + .describe("Height in pixels (optional, defaults to current)"), + }), + outputSchema: z.object({ + success: z.boolean(), + code: z.string(), + height: z.number(), + }), + }, + async (args) => { + // Update scene state + sceneStateRef.current.code = args.code; + if (args.height !== undefined) { + sceneStateRef.current.height = args.height; + } + sceneStateRef.current.error = null; + + const result = { + success: true, + code: args.code, + height: sceneStateRef.current.height, + }; + + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + }, + ); + + // Tool: get-scene-info - Get current scene state and any errors + app.registerTool( + "get-scene-info", + { + title: "Get Scene Info", + description: + "Get the current Three.js scene state including source code, dimensions, rendering status, and any errors.", + outputSchema: z.object({ + code: z.string().nullable(), + height: z.number(), + error: z.string().nullable(), + isRendering: z.boolean(), + }), + }, + async () => { + const state = sceneStateRef.current; + const result = { + code: state.code, + height: state.height, + error: state.error, + isRendering: state.isRendering, + }; + + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + }, + ); } // ============================================================================= @@ -54,14 +159,38 @@ function McpAppWrapper() { const [toolResult, setToolResult] = useState(null); const [hostContext, setHostContext] = useState(null); + // Scene state for widget interaction tools + const sceneStateRef = useRef({ + code: null, + height: 400, + error: null, + isRendering: false, + }); + + // Reference to app for tools to access updateModelContext + const appRef = useRef(null); + const { app, error } = useApp({ appInfo: { name: "Three.js Widget", version: "1.0.0" }, - capabilities: {}, + capabilities: { tools: {} }, onAppCreated: (app) => { + appRef.current = app; + + // Register widget interaction tools before connect() + registerWidgetTools(app, sceneStateRef); + // Complete tool input (streaming finished) app.ontoolinput = (params) => { - setToolInputs(params.arguments as Record); + const args = params.arguments as Record; + setToolInputs(args); setToolInputsPartial(null); + // Update scene state from tool input + if (typeof args.code === "string") { + sceneStateRef.current.code = args.code; + } + if (typeof args.height === "number") { + sceneStateRef.current.height = args.height; + } }; // Partial tool input (streaming in progress) app.ontoolinputpartial = (params) => { @@ -106,6 +235,33 @@ function McpAppWrapper() { [app], ); + // Callback for scene to report errors + const onSceneError = useCallback((sceneError: string | null) => { + sceneStateRef.current.error = sceneError; + + // Send errors to model context for awareness + if (sceneError && appRef.current) { + appRef.current.updateModelContext({ + content: [ + { + type: "text" as const, + text: `Three.js Scene Error: ${sceneError}`, + }, + ], + structuredContent: { + type: "scene_error", + error: sceneError, + timestamp: new Date().toISOString(), + }, + }); + } + }, []); + + // Callback for scene to report rendering state + const onSceneRendering = useCallback((isRendering: boolean) => { + sceneStateRef.current.isRendering = isRendering; + }, []); + if (error) { return
Error: {error.message}
; } @@ -124,6 +280,8 @@ function McpAppWrapper() { sendMessage={sendMessage} openLink={openLink} sendLog={sendLog} + onSceneError={onSceneError} + onSceneRendering={onSceneRendering} /> ); } diff --git a/examples/threejs-server/src/threejs-app.tsx b/examples/threejs-server/src/threejs-app.tsx index e6b773e3..261801ef 100644 --- a/examples/threejs-server/src/threejs-app.tsx +++ b/examples/threejs-server/src/threejs-app.tsx @@ -173,6 +173,8 @@ export default function ThreeJSApp({ sendMessage: _sendMessage, openLink: _openLink, sendLog: _sendLog, + onSceneError, + onSceneRendering, }: ThreeJSAppProps) { const [error, setError] = useState(null); const canvasRef = useRef(null); @@ -195,11 +197,21 @@ export default function ThreeJSApp({ if (!code || !canvasRef.current || !containerRef.current) return; setError(null); + onSceneError(null); + onSceneRendering(true); + const width = containerRef.current.offsetWidth || 800; - executeThreeCode(code, canvasRef.current, width, height).catch((e) => - setError(e instanceof Error ? e.message : "Unknown error"), - ); - }, [code, height]); + executeThreeCode(code, canvasRef.current, width, height) + .then(() => { + onSceneRendering(true); + }) + .catch((e) => { + const errorMessage = e instanceof Error ? e.message : "Unknown error"; + setError(errorMessage); + onSceneError(errorMessage); + onSceneRendering(false); + }); + }, [code, height, onSceneError, onSceneRendering]); if (isStreaming || !code) { return ( diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index 5c9acfd5..8ce90b82 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -420,7 +420,10 @@ app.registerTool( const response = result.structuredContent as unknown as ToolResponse; if (response && response.page) { initialUrl = response.page.url; - addNode(response.page.url, response.page.title, "default", { x: 0, y: 0 }); + addNode(response.page.url, response.page.title, "default", { + x: 0, + y: 0, + }); graph.warmupTicks(100); handleToolResultData(result); graph.centerAt(0, 0, 500); @@ -465,7 +468,9 @@ app.registerTool( if (!currentUrl) { return { - content: [{ type: "text" as const, text: "No article is currently selected" }], + content: [ + { type: "text" as const, text: "No article is currently selected" }, + ], structuredContent: { hasSelection: false, article: null, @@ -477,7 +482,12 @@ app.registerTool( if (!node) { return { - content: [{ type: "text" as const, text: "Selected article not found in graph" }], + content: [ + { + type: "text" as const, + text: "Selected article not found in graph", + }, + ], structuredContent: { hasSelection: false, article: null, @@ -512,7 +522,8 @@ app.registerTool( "highlight-node", { title: "Highlight Node", - description: "Highlight and center on a specific node in the graph by title or URL", + description: + "Highlight and center on a specific node in the graph by title or URL", inputSchema: z.object({ identifier: z .string() @@ -526,8 +537,7 @@ app.registerTool( // Find node by title (case-insensitive partial match) or exact URL const node = graphData.nodes.find( (n) => - n.url === identifier || - n.title.toLowerCase().includes(lowerIdentifier), + n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier), ); if (!node) { From ae4157bc38213bb85792dc253f3177e66089ce17 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sat, 17 Jan 2026 11:02:48 +0000 Subject: [PATCH 16/17] feat: Add expand-node tool to wiki-explorer and update tool descriptions Wiki Explorer: - Add expand-node tool - the critical missing tool for graph exploration - Claude can now programmatically expand nodes to discover linked articles Server descriptions updated to mention widget tools: - map-server: navigate-to, get-current-view - pdf-server: go-to-page, get-page-text, search-text, set-zoom, get-document-info - budget-allocator: get-allocations, set-allocation, set-total-budget, etc. - shadertoy: set-shader-source, get-shader-info - wiki-explorer: expand-node, search-article, highlight-node, etc. All descriptions now mention 'Use list_widget_tools to discover available actions.' --- examples/budget-allocator-server/server.ts | 2 +- examples/map-server/server.ts | 2 +- examples/pdf-server/server.ts | 4 +- examples/shadertoy-server/server.ts | 4 +- examples/wiki-explorer-server/server.ts | 2 +- examples/wiki-explorer-server/src/mcp-app.ts | 117 +++++++++++++++++++ 6 files changed, 126 insertions(+), 5 deletions(-) diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts index e5229182..9825781e 100755 --- a/examples/budget-allocator-server/server.ts +++ b/examples/budget-allocator-server/server.ts @@ -269,7 +269,7 @@ export function createServer(): McpServer { { title: "Get Budget Data", description: - "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage", + "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage. The widget exposes tools: get-allocations (current allocations), set-allocation (adjust a category), set-total-budget (change budget), set-company-stage (change benchmark stage), get-benchmark-comparison (compare to industry). Use list_widget_tools to discover available actions.", inputSchema: {}, outputSchema: BudgetDataResponseSchema, _meta: { ui: { resourceUri } }, diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts index e6ff5642..d2f54fd9 100644 --- a/examples/map-server/server.ts +++ b/examples/map-server/server.ts @@ -148,7 +148,7 @@ export function createServer(): McpServer { { title: "Show Map", description: - "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location.", + "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location. The widget exposes tools: navigate-to (fly to a new location), get-current-view (get camera position and visible bounds). Use list_widget_tools to discover available actions.", inputSchema: { west: z .number() diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 77e6f334..6e1c48d7 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -136,7 +136,9 @@ Use this tool when the user asks to view, display, read, or open a PDF. Accepts: - URLs from list_pdfs (preloaded PDFs) - Any arxiv.org URL (loaded dynamically) -The viewer supports zoom, navigation, text selection, and fullscreen mode.`, +The viewer supports zoom, navigation, text selection, and fullscreen mode. + +The widget exposes tools: go-to-page (navigate to a page), get-page-text (extract text), search-text (find text in document), set-zoom (adjust zoom level), get-document-info (get metadata). Use list_widget_tools to discover available actions.`, inputSchema: { url: z .string() diff --git a/examples/shadertoy-server/server.ts b/examples/shadertoy-server/server.ts index 1c96feab..69e21c52 100644 --- a/examples/shadertoy-server/server.ts +++ b/examples/shadertoy-server/server.ts @@ -49,7 +49,9 @@ LIMITATIONS - Do NOT use: - VR features (mainVR not available) For procedural noise: - float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }`; + float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); } + +The widget exposes tools: set-shader-source (update shader code), get-shader-info (get source and errors). Compilation errors are also sent to model context. Use list_widget_tools to discover available actions.`; const DEFAULT_FRAGMENT_SHADER = `void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; diff --git a/examples/wiki-explorer-server/server.ts b/examples/wiki-explorer-server/server.ts index fe2e89b5..9ca2ad2c 100644 --- a/examples/wiki-explorer-server/server.ts +++ b/examples/wiki-explorer-server/server.ts @@ -86,7 +86,7 @@ export function createServer(): McpServer { { title: "Get First-Degree Links", description: - "Returns all Wikipedia pages that the given page links to directly.", + "Returns all Wikipedia pages that the given page links to directly. The widget exposes tools: expand-node (explore a node's links - the key interaction!), search-article (find articles), get-current-article (get displayed article), highlight-node (highlight a graph node), get-visible-nodes (list visible nodes). Use list_widget_tools to discover available actions.", inputSchema: z.object({ url: z .string() diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index 8ce90b82..04d5d8ee 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -576,6 +576,123 @@ app.registerTool( }, ); +// Tool: Expand a node to show its linked pages +app.registerTool( + "expand-node", + { + title: "Expand Node", + description: + "Expand a node to fetch and display all Wikipedia pages it links to. This is the core way to explore the graph.", + inputSchema: z.object({ + identifier: z + .string() + .describe("The title or URL of the node to expand"), + }), + }, + async (args) => { + const { identifier } = args as { identifier: string }; + const lowerIdentifier = identifier.toLowerCase(); + + // Find node by title (case-insensitive partial match) or exact URL + const node = graphData.nodes.find( + (n) => + n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier), + ); + + if (!node) { + return { + content: [ + { type: "text" as const, text: `Node not found: ${identifier}` }, + ], + structuredContent: { + success: false, + error: "Node not found in graph", + availableNodes: graphData.nodes.map((n) => n.title), + }, + }; + } + + if (node.state === "expanded") { + return { + content: [ + { + type: "text" as const, + text: `Node "${node.title}" is already expanded`, + }, + ], + structuredContent: { + success: true, + alreadyExpanded: true, + node: { url: node.url, title: node.title }, + }, + }; + } + + if (node.state === "error") { + return { + content: [ + { + type: "text" as const, + text: `Node "${node.title}" has an error: ${node.errorMessage}`, + }, + ], + structuredContent: { + success: false, + error: node.errorMessage, + }, + }; + } + + try { + // Fetch the linked pages using the server tool + const result = await app.callServerTool({ + name: "get-first-degree-links", + arguments: { url: node.url }, + }); + + graph.warmupTicks(0); + handleToolResultData(result); + + const response = result.structuredContent as unknown as ToolResponse; + const linksAdded = response?.links?.length ?? 0; + + // Center on the expanded node + if (node.x !== undefined && node.y !== undefined) { + graph.centerAt(node.x, node.y, 500); + } + + return { + content: [ + { + type: "text" as const, + text: `Expanded "${node.title}" - found ${linksAdded} linked articles`, + }, + ], + structuredContent: { + success: true, + node: { url: node.url, title: node.title }, + linksAdded, + }, + }; + } catch (e) { + setNodeState(node.url, "error", "Request failed"); + updateGraph(); + return { + content: [ + { + type: "text" as const, + text: `Failed to expand "${node.title}": ${e instanceof Error ? e.message : String(e)}`, + }, + ], + structuredContent: { + success: false, + error: String(e), + }, + }; + } + }, +); + // Tool: Get list of currently visible nodes in the graph app.registerTool( "get-visible-nodes", From c8ff8c12ed83bc45aa2624c642c23122f7640046 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sat, 17 Jan 2026 11:11:41 +0000 Subject: [PATCH 17/17] refactor: Simplify tool descriptions - remove client implementation details The server tool descriptions now just mention that widgets are interactive and can be controlled, without teaching the model about list_widget_tools (which is the client's responsibility to teach). Before: 'The widget exposes tools: X, Y, Z. Use list_widget_tools to discover...' After: 'The widget is interactive and exposes tools for X and Y.' --- examples/budget-allocator-server/server.ts | 2 +- examples/map-server/server.ts | 2 +- examples/pdf-server/server.ts | 4 +--- examples/shadertoy-server/server.ts | 2 +- examples/wiki-explorer-server/server.ts | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts index 9825781e..04fba11f 100755 --- a/examples/budget-allocator-server/server.ts +++ b/examples/budget-allocator-server/server.ts @@ -269,7 +269,7 @@ export function createServer(): McpServer { { title: "Get Budget Data", description: - "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage. The widget exposes tools: get-allocations (current allocations), set-allocation (adjust a category), set-total-budget (change budget), set-company-stage (change benchmark stage), get-benchmark-comparison (compare to industry). Use list_widget_tools to discover available actions.", + "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage. The widget is interactive and exposes tools for reading/modifying allocations, adjusting budgets, and comparing against industry benchmarks.", inputSchema: {}, outputSchema: BudgetDataResponseSchema, _meta: { ui: { resourceUri } }, diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts index d2f54fd9..a64594f9 100644 --- a/examples/map-server/server.ts +++ b/examples/map-server/server.ts @@ -148,7 +148,7 @@ export function createServer(): McpServer { { title: "Show Map", description: - "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location. The widget exposes tools: navigate-to (fly to a new location), get-current-view (get camera position and visible bounds). Use list_widget_tools to discover available actions.", + "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location. The widget is interactive and exposes tools for navigation (fly to locations) and querying the current view.", inputSchema: { west: z .number() diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 6e1c48d7..fa51a735 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -136,9 +136,7 @@ Use this tool when the user asks to view, display, read, or open a PDF. Accepts: - URLs from list_pdfs (preloaded PDFs) - Any arxiv.org URL (loaded dynamically) -The viewer supports zoom, navigation, text selection, and fullscreen mode. - -The widget exposes tools: go-to-page (navigate to a page), get-page-text (extract text), search-text (find text in document), set-zoom (adjust zoom level), get-document-info (get metadata). Use list_widget_tools to discover available actions.`, +The viewer supports zoom, navigation, text selection, and fullscreen mode. The widget is interactive and exposes tools for page navigation, text extraction, searching, and zoom control.`, inputSchema: { url: z .string() diff --git a/examples/shadertoy-server/server.ts b/examples/shadertoy-server/server.ts index 69e21c52..7902673a 100644 --- a/examples/shadertoy-server/server.ts +++ b/examples/shadertoy-server/server.ts @@ -51,7 +51,7 @@ LIMITATIONS - Do NOT use: For procedural noise: float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); } -The widget exposes tools: set-shader-source (update shader code), get-shader-info (get source and errors). Compilation errors are also sent to model context. Use list_widget_tools to discover available actions.`; +The widget is interactive and exposes tools for updating shader source code and querying compilation status. Compilation errors are sent to model context automatically.`; const DEFAULT_FRAGMENT_SHADER = `void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; diff --git a/examples/wiki-explorer-server/server.ts b/examples/wiki-explorer-server/server.ts index 9ca2ad2c..59d5e18e 100644 --- a/examples/wiki-explorer-server/server.ts +++ b/examples/wiki-explorer-server/server.ts @@ -86,7 +86,7 @@ export function createServer(): McpServer { { title: "Get First-Degree Links", description: - "Returns all Wikipedia pages that the given page links to directly. The widget exposes tools: expand-node (explore a node's links - the key interaction!), search-article (find articles), get-current-article (get displayed article), highlight-node (highlight a graph node), get-visible-nodes (list visible nodes). Use list_widget_tools to discover available actions.", + "Returns all Wikipedia pages that the given page links to directly. The widget is interactive and exposes tools for exploring the graph (expanding nodes to see their links), searching for articles, and querying visible nodes.", inputSchema: z.object({ url: z .string()