From b17ce8dc0a7f739ccf6dcd72b496e707b0405ea1 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Mon, 21 Sep 2026 17:09:50 +0200 Subject: [PATCH 1/2] fix: use object inputs in the WebMCP bridge --- extension/src/__tests__/content-main.test.ts | 209 +++++++++++++++++++ extension/src/content-main.ts | 23 +- 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 extension/src/__tests__/content-main.test.ts diff --git a/extension/src/__tests__/content-main.test.ts b/extension/src/__tests__/content-main.test.ts new file mode 100644 index 0000000..4bc79aa --- /dev/null +++ b/extension/src/__tests__/content-main.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, expect, it, type MockInstance, vi } from "vitest"; +import type { PageMessage, PageModelContext, PageToolResultMessage } from "../types"; + +const tool = { name: "echo", description: "Echo input" }; +const result = '{"content":[{"type":"text","text":"hello"}]}'; +let listeners: MockInstance; + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + listeners = vi.spyOn(window, "addEventListener"); +}); + +afterEach(() => { + for (const [type, listener] of listeners.mock.calls) { + window.removeEventListener(type, listener); + } + delete document.modelContext; + delete navigator.modelContextTesting; + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function pageApi(mode: "modern" | "legacy" | "polyfill") { + const handled: unknown[] = []; + const execute = vi.fn>(async (_tool, input) => { + if (mode === "modern" && typeof input === "string") { + throw new TypeError("Input must be an object"); + } + if (mode === "legacy") { + const serialized = String(input); + try { + handled.push(JSON.parse(serialized)); + } catch { + throw new DOMException("Failed to parse input arguments", "UnknownError"); + } + } else { + handled.push(JSON.parse(typeof input === "string" ? input : JSON.stringify(input))); + } + return result; + }); + document.modelContext = Object.assign(new EventTarget(), { + getTools: async () => [tool], + executeTool: execute, + }); + return { execute, handled }; +} + +async function loadBridge() { + const posted: PageMessage[] = []; + vi.spyOn(window, "postMessage").mockImplementation((message) => posted.push(message)); + await import("../content-main"); + function send(data: PageMessage) { + window.dispatchEvent(new MessageEvent("message", { source: window, data })); + } + return { + call(argsJson = '{"message":"hello"}') { + send({ type: "WEBMCP_EXECUTE_TOOL", requestId: "request-1", toolName: "echo", argsJson }); + }, + cancel() { + send({ type: "WEBMCP_CANCEL_TOOL", requestId: "request-1" }); + }, + async response() { + return vi.waitFor(() => { + const response = posted.find( + (message): message is PageToolResultMessage => + message.type === "WEBMCP_TOOL_RESULT" && message.requestId === "request-1", + ); + if (!response) throw new Error("Waiting for bridge response"); + return response; + }); + }, + }; +} + +it.each(["modern", "polyfill"] as const)("passes objects directly to the %s API", async (mode) => { + const { execute, handled } = pageApi(mode); + const bridge = await loadBridge(); + bridge.call(); + expect(await bridge.response()).toEqual({ + type: "WEBMCP_TOOL_RESULT", + requestId: "request-1", + result, + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][1]).toEqual({ message: "hello" }); + expect(handled).toEqual([{ message: "hello" }]); +}); + +it("retries the legacy parse failure with the same tool and signal, executing once", async () => { + const { execute, handled } = pageApi("legacy"); + const bridge = await loadBridge(); + bridge.call(); + expect((await bridge.response()).result).toBe(result); + expect(execute).toHaveBeenCalledTimes(2); + const [first, retry] = execute.mock.calls; + expect(first[1]).toEqual({ message: "hello" }); + expect(retry[1]).toBe('{"message":"hello"}'); + expect(retry[0]).toBe(first[0]); + expect(retry[2]?.signal).toBe(first[2]?.signal); + expect(handled).toEqual([{ message: "hello" }]); +}); + +it.each([ + ["legacy", '{"toString":"hello"}', { toString: "hello" }], + ["modern", '{"toString":"hello"}', { toString: "hello" }], + ["polyfill", '{"toString":"hello"}', { toString: "hello" }], + ["legacy", '["{}"]', ["{}"]], + ["modern", '["{}"]', ["{}"]], + ["polyfill", '["{}"]', ["{}"]], +] as const)("preserves %s input %s through serialization", async (mode, argsJson, input) => { + const { handled } = pageApi(mode); + const bridge = await loadBridge(); + bridge.call(argsJson); + expect((await bridge.response()).result).toBe(result); + expect(handled).toEqual([input]); + expect(Object.getOwnPropertySymbols(handled[0] as object)).toEqual([]); +}); + +it.each([ + new TypeError("Tool failed"), + new DOMException("Tool execution failed: Failed to parse input", "UnknownError"), + new DOMException("Failed to parse input arguments", "AbortError"), +])("reports %s without executing the tool twice", async (error) => { + const { execute } = pageApi("polyfill"); + let executions = 0; + execute.mockImplementation(async () => { + executions++; + throw error; + }); + const bridge = await loadBridge(); + bridge.call(); + expect((await bridge.response()).error).toContain(error.message); + expect(executions).toBe(1); +}); + +it.each([ + "not json", + "null", + "42", + '"a string"', +])("rejects invalid input %s before execution", async (input) => { + const { execute, handled } = pageApi("polyfill"); + const bridge = await loadBridge(); + bridge.call(input); + expect((await bridge.response()).error).toBeTruthy(); + expect(execute).not.toHaveBeenCalled(); + expect(handled).toEqual([]); +}); + +it("does not retry a legacy parse failure after cancellation", async () => { + const { execute } = pageApi("legacy"); + let reject!: (reason: unknown) => void; + execute.mockImplementation( + () => + new Promise((_resolve, rejectCall) => { + reject = rejectCall; + }), + ); + const bridge = await loadBridge(); + bridge.call(); + await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(1)); + bridge.cancel(); + expect(execute.mock.calls[0][2]?.signal?.aborted).toBe(true); + reject(new DOMException("Failed to parse input arguments", "UnknownError")); + expect((await bridge.response()).error).toContain("Failed to parse input"); + expect(execute).toHaveBeenCalledTimes(1); +}); + +it.each(["modern", "legacy"] as const)("cancels an in-flight %s call", async (mode) => { + const { execute } = pageApi(mode); + let started = false; + execute.mockImplementation(async (_tool, input, options) => { + if (mode === "legacy" && typeof input !== "string") { + throw new DOMException("Failed to parse input arguments", "UnknownError"); + } + const signal = options?.signal; + if (!signal) throw new Error("Missing execution signal"); + started = true; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + }); + const bridge = await loadBridge(); + bridge.call(); + await vi.waitFor(() => expect(started).toBe(true)); + bridge.cancel(); + expect((await bridge.response()).error).toContain("abort"); + expect(execute).toHaveBeenCalledTimes(mode === "legacy" ? 2 : 1); +}); + +it("keeps JSON strings for pages exposing only the legacy testing API", async () => { + const execute = vi.fn(async () => result); + navigator.modelContextTesting = { + listTools: () => [tool], + executeTool: execute, + registerToolsChangedCallback: () => {}, + }; + const bridge = await loadBridge(); + bridge.call(); + expect((await bridge.response()).result).toBe(result); + expect(execute).toHaveBeenCalledWith("echo", '{"message":"hello"}', { + signal: expect.any(AbortSignal), + }); + expect(execute).toHaveBeenCalledTimes(1); +}); diff --git a/extension/src/content-main.ts b/extension/src/content-main.ts index 377b213..360ab19 100644 --- a/extension/src/content-main.ts +++ b/extension/src/content-main.ts @@ -43,18 +43,29 @@ function createModelContextApi(mc: PageModelContext): PageToolApi { })); }, async execute(toolName, argsJson, signal) { + const input = JSON.parse(argsJson); + if (input === null || typeof input !== "object") { + throw new TypeError("Input JSON must be an object"); + } + // Force legacy DOMString conversion to fail parsing before execution. + // Object-input APIs serialize JSON, which ignores symbol properties. + Object.defineProperty(input, Symbol.toPrimitive, { value: () => "[object Object]" }); const tools = await getTools(); const tool = tools.find((t: PageRegisteredTool) => t.name === toolName); if (!tool) throw new Error(`Tool "${toolName}" not found`); try { - return await executeTool(tool, argsJson, { signal }); + return await executeTool(tool, input, { signal }); } catch (err) { - // Future Chrome may take an object instead of a JSON string. A - // TypeError here is argument conversion, so the tool never ran. - if (err instanceof TypeError) { - return await executeTool(tool, JSON.parse(argsJson) as object, { signal }); + // Chrome <=154 parses a JSON string before starting the tool. + if ( + signal.aborted || + !(err instanceof DOMException) || + err.name !== "UnknownError" || + !err.message.startsWith("Failed to parse input") + ) { + throw err; } - throw err; + return await executeTool(tool, argsJson, { signal }); } }, onChange(callback) { From eab3b1aa4b2b643683c3023187413b78395b3724 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Wed, 23 Sep 2026 12:03:29 +0200 Subject: [PATCH 2/2] fix: match native executeTool input defaults --- CHANGELOG.md | 5 +-- docs/api.md | 10 +++--- examples/native-harness/src/App.test.tsx | 16 ++++++++- examples/native-harness/src/App.tsx | 19 ++++++++-- src/polyfill/__tests__/consumer-api.test.ts | 40 ++++++++++++++++++--- src/polyfill/index.ts | 3 +- src/types.ts | 5 ++- 7 files changed, 83 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b15984..11d1464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ All notable changes to `webmcp-react` are documented here. The format is based o JSON serialization, matching Chrome 155.0.8052.0+. Handlers receive an independent parsed copy; getters and `toJSON()` follow JSON semantics. Invalid or unserializable inputs reject before execution, with serialization exceptions preserved. The input - parameter is optional in the type signature, but callers must pass an object (`{}` for - tools without arguments); omitted input rejects with `TypeError`. + defaults to a fresh `{}` when both input and options are omitted or `undefined`. + With supplied options, callers must pass an object (`{}` for tools without arguments); + `undefined` input rejects with `TypeError`. - Legacy JSON-string inputs remain supported by the polyfill in 1.x. Their deprecation is planned for 2.x, without removal in 2.x. The React hook's `execute()` API is unchanged. diff --git a/docs/api.md b/docs/api.md index a03065d..5b4b298 100644 --- a/docs/api.md +++ b/docs/api.md @@ -129,10 +129,12 @@ When native WebMCP is unavailable, the provider installs a polyfill that exposes | 154 | `RegisteredTool.inputSchema` is an object (was a JSON string) | | 155 | `executeTool` takes object inputs instead of JSON strings (155.0.8052.0+) | -Pass an object to `executeTool`, using `{}` for tools without arguments. Although the -input parameter is optional in the browser signature, omitted input, `undefined`, `null`, -and non-object values reject with `TypeError`. This does not change the React hook's -`execute()` convenience, which defaults to `{}`. +Pass an object to `executeTool`, using `{}` for tools without arguments. When options +are omitted or `undefined`, omitting input or passing `undefined` defaults to a fresh `{}`. +When options are supplied (including `{}` or `null`), `undefined` input rejects with +`TypeError`; pass `{}` explicitly, for example `executeTool(tool, {}, { signal })`. +`null` and other non-object inputs also reject with `TypeError`. This does not change +the React hook's `execute()` convenience, which defaults to `{}`. Object inputs follow JSON serialization rules: nested `undefined` properties are omitted, `toJSON()` is respected, and the handler receives an independent copy. Circular references, diff --git a/examples/native-harness/src/App.test.tsx b/examples/native-harness/src/App.test.tsx index 0a94b23..421fee2 100644 --- a/examples/native-harness/src/App.test.tsx +++ b/examples/native-harness/src/App.test.tsx @@ -86,7 +86,14 @@ describe("native harness consumer probes", () => { const output = await run(); await expectFinished(output); expect(output).toHaveTextContent("PASS: object input serialized and cloned"); - expect(output).toHaveTextContent("PASS: omitted input rejects TypeError before handler"); + expect(output).toHaveTextContent("PASS: undefined input defaults to an empty object"); + expect(output).toHaveTextContent( + "PASS: undefined input and options defaults to an empty object", + ); + expect(output).toHaveTextContent( + "PASS: undefined with options input rejects TypeError before handler", + ); + expect(output).toHaveTextContent("PASS: omitted input defaults to an empty object"); expect(output).toHaveTextContent("PASS: circular input rejects TypeError before handler"); expect(output).toHaveTextContent("PASS: legacy JSON string accepted"); }); @@ -96,6 +103,13 @@ describe("native harness consumer probes", () => { const output = await run(); await expectFinished(output); expect(output).toHaveTextContent("PASS: object input serialized and cloned"); + expect(output).toHaveTextContent("PASS: undefined input defaults to an empty object"); + expect(output).toHaveTextContent( + "PASS: undefined input and options defaults to an empty object", + ); + expect(output).toHaveTextContent( + "PASS: undefined with options input rejects TypeError before handler", + ); expect(output).toHaveTextContent("PASS: legacy JSON string rejects TypeError"); }); diff --git a/examples/native-harness/src/App.tsx b/examples/native-harness/src/App.tsx index 914d669..18ddc2f 100644 --- a/examples/native-harness/src/App.tsx +++ b/examples/native-harness/src/App.tsx @@ -144,11 +144,26 @@ async function runSelfTest(log: (line: string) => void) { : "FAIL: object input serialization or cloning", ); + const defaults: [string, () => Promise][] = [ + ["omitted input", () => executeTool(probe)], + ["undefined input", () => executeTool(probe, undefined)], + ["undefined input and options", () => executeTool(probe, undefined, undefined)], + ]; + for (const [label, run] of defaults) { + const before = calls; + const previous = received; + await run(); + log( + calls === before + 1 && received !== previous && JSON.stringify(received) === "{}" + ? `PASS: ${label} defaults to an empty object` + : `FAIL: ${label} default`, + ); + } + const circular: Record = {}; circular.self = circular; const invalid: [string, () => Promise][] = [ - ["omitted", () => executeTool(probe)], - ["undefined", () => executeTool(probe, undefined)], + ["undefined with options", () => executeTool(probe, undefined, {})], ["null", () => executeTool(probe, null as unknown as object)], ["circular", () => executeTool(probe, circular)], ["BigInt", () => executeTool(probe, { value: BigInt(1) })], diff --git a/src/polyfill/__tests__/consumer-api.test.ts b/src/polyfill/__tests__/consumer-api.test.ts index 2309934..8affda3 100644 --- a/src/polyfill/__tests__/consumer-api.test.ts +++ b/src/polyfill/__tests__/consumer-api.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { CallToolResult, + ExecuteToolOptions, InputSchema, InputSchemaProperty, ModelContext, @@ -110,7 +111,6 @@ describe("document.modelContext.executeTool (polyfill)", () => { }); it.each([ - undefined, null, 42, true, @@ -125,17 +125,49 @@ describe("document.modelContext.executeTool (polyfill)", () => { expect(execute).not.toHaveBeenCalled(); }); - it("requires an explicit empty object for a tool without arguments", async () => { + it.each<[string, [input?: object, options?: ExecuteToolOptions]]>([ + ["omitted input", []], + ["undefined input", [undefined]], + ["undefined input and options", [undefined, undefined]], + ])("defaults %s to a fresh empty object", async (_label, args) => { + installPolyfill(); + const execute = vi.fn(async (_input: Record) => ({ content: [] })); + await mc().registerTool(makeTool({ inputSchema: undefined, execute })); + const [tool] = await mc().getTools(); + await expect(mc().executeTool(tool, ...args)).resolves.toBe('{"content":[]}'); + await expect(mc().executeTool(tool, ...args)).resolves.toBe('{"content":[]}'); + expect(execute).toHaveBeenCalledTimes(2); + const [first, second] = execute.mock.calls; + expect(first[0]).toEqual({}); + expect(second[0]).toEqual({}); + expect(first[0]).not.toBe(second[0]); + }); + + it.each<[string, ExecuteToolOptions]>([ + ["empty options", {}], + ["undefined signal", { signal: undefined }], + ["execution signal", { signal: new AbortController().signal }], + ["null options", null as unknown as ExecuteToolOptions], + ])("rejects undefined input with %s", async (_label, options) => { installPolyfill(); const execute = vi.fn(async () => ({ content: [] })); await mc().registerTool(makeTool({ inputSchema: undefined, execute })); const [tool] = await mc().getTools(); - await expect(mc().executeTool(tool)).rejects.toBeInstanceOf(TypeError); + await expect(mc().executeTool(tool, undefined, options)).rejects.toBeInstanceOf(TypeError); expect(execute).not.toHaveBeenCalled(); - await expect(mc().executeTool(tool, {})).resolves.toBe('{"content":[]}'); + await expect(mc().executeTool(tool, {}, options)).resolves.toBe('{"content":[]}'); expect(execute).toHaveBeenCalledTimes(1); }); + it("validates defaulted input against the tool schema", async () => { + installPolyfill(); + const execute = vi.fn(async () => ({ content: [] })); + await mc().registerTool(makeTool({ execute })); + const [tool] = await mc().getTools(); + await expect(mc().executeTool(tool)).rejects.toMatchObject({ name: "OperationError" }); + expect(execute).not.toHaveBeenCalled(); + }); + it("rejects UnknownError for a stale/unregistered tool", async () => { installPolyfill(); const stale: RegisteredTool = { name: "ghost", description: "gone" }; diff --git a/src/polyfill/index.ts b/src/polyfill/index.ts index 97764c6..b9b0841 100644 --- a/src/polyfill/index.ts +++ b/src/polyfill/index.ts @@ -66,7 +66,8 @@ class PolyfillModelContext extends EventTarget { if (!registered) { return Promise.reject(new DOMException(`Tool "${tool?.name}" not found`, "UnknownError")); } - return runTool(registered, inputArguments, options?.signal); + const input = inputArguments === undefined && options === undefined ? {} : inputArguments; + return runTool(registered, input, options?.signal); } get ontoolchange(): ((ev: Event) => unknown) | null { diff --git a/src/types.ts b/src/types.ts index 2f3baa2..ae287de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -178,7 +178,10 @@ export interface ModelContextGetToolOptions { export interface ModelContext extends EventTarget { registerTool(tool: ToolDescriptor, options?: RegisterToolOptions): Promise; getTools?(options?: ModelContextGetToolOptions): Promise; - /** Pass an object ({} for no arguments). JSON strings are a legacy polyfill extension. */ + /** + * Input defaults to {} only when input and options are omitted or undefined. + * Pass {} with options. JSON strings are a legacy polyfill extension. + */ executeTool?( tool: RegisteredTool, inputArguments?: string | object,