diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d1464..299dd3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,17 @@ All notable changes to `webmcp-react` are documented here. The format is based o ## Unreleased -### Changed +### Added + +- The polyfill's `document.modelContext.executeTool()` 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). + +### Compatibility -- Object inputs to the polyfill's `document.modelContext.executeTool()` now pass through - 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 - 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`. +- The 1.x polyfill continues to pass object inputs by reference, preserve input errors, + and check cancellation before input parsing or validation. Native Chrome 155.0.8052.0+ + JSON-serializes object inputs instead; use plain JSON-compatible objects for portable calls. - 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 5b4b298..ed04197 100644 --- a/docs/api.md +++ b/docs/api.md @@ -116,7 +116,7 @@ When native WebMCP is unavailable, the provider installs a polyfill that exposes - `document.modelContext.getTools(options?)` / `executeTool(tool, inputArguments?, options?)` — the consumer API. `getTools()` resolves sorted, fresh `RegisteredTool` objects whose `inputSchema` is a deep-copied **object**; - `executeTool` serializes object inputs to JSON before passing a parsed copy to the tool, + `executeTool` accepts object inputs by reference or parses JSON-string inputs, forwards `options.signal` into the tool's execution signal, and — unlike native Chrome — validates input against `inputSchema` (`OperationError`). - `navigator.modelContextTesting` — **deprecated** wrapper over the same engine @@ -131,17 +131,23 @@ When native WebMCP is unavailable, the provider installs a polyfill that exposes 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, -BigInt values that cannot be serialized, and serialization returning `undefined` reject -with `TypeError`. Exceptions thrown by getters or `toJSON()` reject with the original -exception. These failures occur before the handler runs. A serialized value that is not -an object, or a tool execution failure, rejects with `UnknownError`. +When options are supplied (including `{}` or `null`), `undefined` input rejects; pass +`{}` explicitly, for example `executeTool(tool, {}, { signal })`. The polyfill uses +`UnknownError` for invalid inputs; native Chrome uses `TypeError` for non-object inputs. +This does not change the React hook's `execute()` convenience, which defaults to `{}`. + +The 1.x polyfill preserves object inputs by reference. It does not JSON-serialize them +or invoke `toJSON()`: nested objects, Dates, `undefined` properties, and other values +reach the handler unchanged, subject to input schema validation. Handler mutations can +therefore affect the caller's object. Invalid JSON strings and non-object inputs reject +with `UnknownError`; schema violations reject with `OperationError`. An already-aborted +execution rejects with the caller's abort reason before input parsing or validation. + +Native Chrome 155 instead JSON-serializes object inputs and passes an independent parsed +copy to the handler. Nested `undefined` properties are omitted and `toJSON()` is applied. +Circular references, BigInt values, and serialization returning `undefined` reject with +`TypeError`; exceptions from serialization reject with the original exception. Use plain +JSON-compatible objects for portable calls across native Chrome and the polyfill. For compatibility, the polyfill also accepts JSON-string inputs and preserves their existing parsing errors (`UnknownError`). Native Chrome through 154 requires strings; diff --git a/examples/native-harness/src/App.test.tsx b/examples/native-harness/src/App.test.tsx index 421fee2..5356456 100644 --- a/examples/native-harness/src/App.test.tsx +++ b/examples/native-harness/src/App.test.tsx @@ -26,8 +26,18 @@ function nativeInputBoundary(mode: "modern" | "legacy" | Error, delayedAbort = f if (mode === "legacy" && typeof input !== "string") { return Promise.reject(new DOMException("Failed to parse input arguments", "UnknownError")); } - if (mode === "modern" && typeof input === "string") { - return Promise.reject(new TypeError("Input must be an object")); + if (mode === "modern") { + if (input === undefined && options === undefined) input = {}; + if (input === null || (typeof input !== "object" && typeof input !== "function")) { + return Promise.reject(new TypeError("Input must be an object")); + } + try { + const serialized = JSON.stringify(input); + if (serialized === undefined) throw new TypeError("Input is not JSON-serializable"); + input = JSON.parse(serialized); + } catch (error) { + return Promise.reject(error); + } } if (delayedAbort && options?.signal) { const toolController = new AbortController(); @@ -82,19 +92,21 @@ async function expectFinished(output: HTMLElement) { } describe("native harness consumer probes", () => { - it("reports real polyfill object serialization, invalid input, and legacy compatibility", async () => { + it("reports polyfill object identity, input errors, and legacy compatibility", async () => { const output = await run(); await expectFinished(output); - expect(output).toHaveTextContent("PASS: object input serialized and cloned"); + expect(output).toHaveTextContent("PASS: polyfill preserves object input"); 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", + "PASS: undefined with options input rejects UnknownError 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: polyfill preserves circular input"); + expect(output).toHaveTextContent("PASS: polyfill preserves BigInt input"); + expect(output).toHaveTextContent("PASS: polyfill preserves toJSON undefined input"); expect(output).toHaveTextContent("PASS: legacy JSON string accepted"); }); diff --git a/examples/native-harness/src/App.tsx b/examples/native-harness/src/App.tsx index 18ddc2f..2d4c607 100644 --- a/examples/native-harness/src/App.tsx +++ b/examples/native-harness/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { WebMCPProvider, useMcpTool, useWebMCPStatus } from "webmcp-react"; +import { useMcpTool, useWebMCPStatus, WebMCPProvider } from "webmcp-react"; import { z } from "zod"; /** @@ -135,14 +135,23 @@ async function runSelfTest(log: (line: string) => void) { log("INFO: modern input probes skipped on legacy native"); } else { const transformed = { nested: { text: "serialized" } }; - await executeTool(probe, { toJSON: () => transformed }); - log( - received !== transformed && - (received as typeof transformed).nested !== transformed.nested && - JSON.stringify(received) === JSON.stringify(transformed) - ? "PASS: object input serialized and cloned" - : "FAIL: object input serialization or cloning", - ); + const input = { toJSON: () => transformed }; + await executeTool(probe, input); + if (isPolyfill) { + log( + received === input + ? "PASS: polyfill preserves object input" + : "FAIL: polyfill changed object input", + ); + } else { + log( + received !== transformed && + (received as typeof transformed).nested !== transformed.nested && + JSON.stringify(received) === JSON.stringify(transformed) + ? "PASS: object input serialized and cloned" + : "FAIL: object input serialization or cloning", + ); + } const defaults: [string, () => Promise][] = [ ["omitted input", () => executeTool(probe)], @@ -162,13 +171,29 @@ async function runSelfTest(log: (line: string) => void) { const circular: Record = {}; circular.self = circular; + const nonJsonInputs: [string, object][] = [ + ["circular", circular], + ["BigInt", { value: BigInt(1) }], + ["toJSON undefined", { toJSON: () => undefined }], + ]; const invalid: [string, () => Promise][] = [ ["undefined with options", () => executeTool(probe, undefined, {})], ["null", () => executeTool(probe, null as unknown as object)], - ["circular", () => executeTool(probe, circular)], - ["BigInt", () => executeTool(probe, { value: BigInt(1) })], - ["toJSON undefined", () => executeTool(probe, { toJSON: () => undefined })], ]; + for (const [label, value] of nonJsonInputs) { + if (isPolyfill) { + const before = calls; + await executeTool(probe, value); + log( + calls === before + 1 && received === value + ? `PASS: polyfill preserves ${label} input` + : `FAIL: polyfill changed ${label} input`, + ); + } else { + invalid.push([label, () => executeTool(probe, value)]); + } + } + const inputError = isPolyfill ? "UnknownError" : "TypeError"; for (const [label, run] of invalid) { const before = calls; try { @@ -176,8 +201,8 @@ async function runSelfTest(log: (line: string) => void) { log(`FAIL: ${label} input resolved`); } catch (err) { log( - errName(err) === "TypeError" && calls === before - ? `PASS: ${label} input rejects TypeError before handler` + errName(err) === inputError && calls === before + ? `PASS: ${label} input rejects ${inputError} before handler` : `FAIL: ${label} input (${errName(err)}, handler calls: ${calls - before})`, ); } @@ -406,18 +431,14 @@ async function runSelfTest(log: (line: string) => void) { */ function StatusPanel() { const { available } = useWebMCPStatus(); - const [detection, setDetection] = useState<"native" | "polyfill" | "checking">( - "checking", - ); + const [detection, setDetection] = useState<"native" | "polyfill" | "checking">("checking"); const [toolchangeCount, setToolchangeCount] = useState(0); // Update detection once available useEffect(() => { if (available) { const mc = document.modelContext; - setDetection( - mc && "__isWebMCPPolyfill" in mc ? "polyfill" : "native", - ); + setDetection(mc && "__isWebMCPPolyfill" in mc ? "polyfill" : "native"); } }, [available]); @@ -455,7 +476,8 @@ function SelfTestPanel() { const handleRunSelfTest = useCallback(() => { setSelftestOutput([]); void runSelfTest((line) => setSelftestOutput((lines) => [...lines, line])).catch( - (err: unknown) => setSelftestOutput((lines) => [...lines, `FAIL: self-test (${errName(err)})`]), + (err: unknown) => + setSelftestOutput((lines) => [...lines, `FAIL: self-test (${errName(err)})`]), ); }, []); diff --git a/src/hooks/__tests__/useMcpTool.test.tsx b/src/hooks/__tests__/useMcpTool.test.tsx index a0bf6aa..eafb9be 100644 --- a/src/hooks/__tests__/useMcpTool.test.tsx +++ b/src/hooks/__tests__/useMcpTool.test.tsx @@ -1340,7 +1340,39 @@ describe("consumer object inputs", () => { expect(onSuccess).toHaveBeenCalledExactlyOnceWith(makeResult("hello world")); }); - it("rejects unserializable consumer input without changing hook state or callbacks", async () => { + it.each(["direct", "consumer"])("preserves non-JSON input through %s execution", async (path) => { + const input = { date: new Date("2026-01-01T00:00:00Z"), value: 1n }; + const handler = vi.fn((args) => { + expect(args).toBe(input); + return makeResult(`${args.date.getUTCFullYear()}: ${args.value}`); + }); + const onSuccess = vi.fn(); + const executeRef = { current: null as ExecuteFn | null }; + const view = renderWithProvider( + + + , + ); + const { mc, tool } = await getConsumer(); + await act(async () => { + if (path === "direct") { + expect(await executeRef.current?.(input)).toEqual(makeResult("2026: 1")); + } else { + expect(JSON.parse((await mc.executeTool(tool, input)) as string)).toEqual( + makeResult("2026: 1"), + ); + } + }); + expect(handler).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledExactlyOnceWith(makeResult("2026: 1")); + expect(view.getByTestId("count").textContent).toBe("1"); + expect(view.getByTestId("result").textContent).toBe("2026: 1"); + }); + + it("rejects invalid consumer input without changing hook state or callbacks", async () => { const handler = vi.fn(() => OK_RESULT); const onSuccess = vi.fn(); const onError = vi.fn(); @@ -1350,10 +1382,10 @@ describe("consumer object inputs", () => { />, ); const { mc, tool } = await getConsumer(); - const input: Record = {}; - input.self = input; await act(async () => { - await expect(mc.executeTool(tool, input)).rejects.toBeInstanceOf(TypeError); + await expect(mc.executeTool(tool, null as unknown as object)).rejects.toMatchObject({ + name: "UnknownError", + }); }); expect(handler).not.toHaveBeenCalled(); expect(onSuccess).not.toHaveBeenCalled(); diff --git a/src/polyfill/__tests__/consumer-api.test.ts b/src/polyfill/__tests__/consumer-api.test.ts index 8affda3..494a419 100644 --- a/src/polyfill/__tests__/consumer-api.test.ts +++ b/src/polyfill/__tests__/consumer-api.test.ts @@ -115,13 +115,13 @@ describe("document.modelContext.executeTool (polyfill)", () => { 42, true, Symbol("input"), - ])("rejects non-object input %s with TypeError", async (input) => { + ])("rejects non-object input %s with UnknownError", async (input) => { installPolyfill(); const execute = vi.fn(async () => ({ content: [] })); await mc().registerTool(makeTool({ inputSchema: undefined, execute })); const [tool] = await mc().getTools(); const pending = mc().executeTool(tool, input as object); - await expect(pending).rejects.toBeInstanceOf(TypeError); + await expect(pending).rejects.toMatchObject({ name: "UnknownError" }); expect(execute).not.toHaveBeenCalled(); }); @@ -153,7 +153,9 @@ describe("document.modelContext.executeTool (polyfill)", () => { const execute = vi.fn(async () => ({ content: [] })); await mc().registerTool(makeTool({ inputSchema: undefined, execute })); const [tool] = await mc().getTools(); - await expect(mc().executeTool(tool, undefined, options)).rejects.toBeInstanceOf(TypeError); + await expect(mc().executeTool(tool, undefined, options)).rejects.toMatchObject({ + name: "UnknownError", + }); expect(execute).not.toHaveBeenCalled(); await expect(mc().executeTool(tool, {}, options)).resolves.toBe('{"content":[]}'); expect(execute).toHaveBeenCalledTimes(1); diff --git a/src/polyfill/__tests__/execute.test.ts b/src/polyfill/__tests__/execute.test.ts index 67a339b..3be84e0 100644 --- a/src/polyfill/__tests__/execute.test.ts +++ b/src/polyfill/__tests__/execute.test.ts @@ -38,98 +38,111 @@ describe("runTool", () => { expect(execute.mock.calls[0][0]).toEqual({ query: "hi" }); }); - it("gives the tool an independent JSON copy of nested input", async () => { + it("passes nested objects by reference", async () => { const input = { nested: { value: "original" } }; const tool = makeTool({ inputSchema: undefined, execute: (args) => { + expect(args).toBe(input); (args.nested as { value: string }).value = "changed"; return OK; }, }); await runTool(tool, input); - expect(input.nested.value).toBe("original"); + expect(input.nested.value).toBe("changed"); }); - it("applies JSON transformations before validating the input schema", async () => { + it("validates the original input without calling toJSON", async () => { + const toJSON = vi.fn(() => "hi"); const execute = vi.fn(async () => OK); - await runTool(makeTool({ execute }), { - query: { toJSON: () => "hi" }, - nested: { omitted: undefined, values: [undefined, Number.NaN] }, + await expect(runTool(makeTool({ execute }), { query: { toJSON } })).rejects.toMatchObject({ + name: "OperationError", }); - expect(execute).toHaveBeenCalledWith( - { query: "hi", nested: { values: [null, null] } }, - { signal: expect.any(AbortSignal) }, - ); + expect(toJSON).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); }); it.each([ - ["array", [1, 2], [1, 2]], - ["custom toJSON", { toJSON: () => ({ query: "hi" }) }, { query: "hi" }], - [ - "callable object", - Object.assign(() => {}, { toJSON: () => ({ query: "hi" }) }), - { query: "hi" }, - ], - ])("accepts a serializable %s", async (_label, input, expected) => { + ["array", [1, 2]], + ["Date", { date: new Date("2026-01-01T00:00:00Z") }], + ["undefined and NaN", { omitted: undefined, values: [undefined, Number.NaN] }], + ["BigInt", { value: 1n }], + ["custom toJSON", { toJSON: () => ({ query: "hi" }) }], + ["undefined toJSON", { toJSON: () => undefined }], + ["primitive toJSON", { toJSON: () => 42 }], + ])("preserves %s input", async (_label, input) => { const execute = vi.fn(async () => OK); await runTool(makeTool({ inputSchema: undefined, execute }), input as object); - expect(execute.mock.calls[0][0]).toEqual(expected); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][0]).toBe(input); }); - it.each([ - [ - "cycle", - () => { - const input: Record = {}; - input.self = input; - return input; - }, - ], - ["BigInt", () => ({ value: 1n })], - ["undefined serialization", () => ({ toJSON: () => undefined })], - ["function", () => () => {}], - ])("rejects %s input without executing the tool", async (_label, makeInput) => { + it("accepts circular input", async () => { + const input: Record = {}; + input.self = input; const execute = vi.fn(async () => OK); - const pending = runTool(makeTool({ inputSchema: undefined, execute }), makeInput()); - await expect(pending).rejects.toBeInstanceOf(TypeError); - expect(execute).not.toHaveBeenCalled(); + await runTool(makeTool({ inputSchema: undefined, execute }), input); + expect(execute.mock.calls[0][0]).toBe(input); }); - it.each(["getter", "toJSON"])("preserves an exception thrown by an input %s", async (kind) => { - const reason = new Error("cannot read input"); - const fail = () => { - throw reason; - }; + it.each(["getter", "toJSON"])("does not invoke an unused input %s", async (kind) => { + const fail = vi.fn(() => { + throw new Error("cannot read input"); + }); const input = kind === "toJSON" ? { toJSON: fail } : Object.defineProperty({}, "query", { get: fail, enumerable: true }); const execute = vi.fn(async () => OK); - const pending = runTool(makeTool({ execute }), input); - await expect(pending).rejects.toBe(reason); - expect(execute).not.toHaveBeenCalled(); + await expect(runTool(makeTool({ inputSchema: undefined, execute }), input)).resolves.toBe( + JSON.stringify(OK), + ); + expect(execute.mock.calls[0][0]).toBe(input); + expect(fail).not.toHaveBeenCalled(); }); - it("rejects an object that serializes to a primitive before executing the tool", async () => { + it("preserves an exception thrown by a getter during schema validation", async () => { + const reason = new Error("cannot read input"); + const input = Object.defineProperty({}, "query", { + get() { + throw reason; + }, + enumerable: true, + }); const execute = vi.fn(async () => OK); - await expect( - runTool(makeTool({ inputSchema: undefined, execute }), { toJSON: () => 42 }), - ).rejects.toThrow(expect.objectContaining({ name: "UnknownError" })); + await expect(runTool(makeTool({ execute }), input)).rejects.toBe(reason); expect(execute).not.toHaveBeenCalled(); }); - it("does not execute if input serialization aborts the caller signal", async () => { - const controller = new AbortController(); - const reason = new Error("cancelled while reading input"); + it.each([ + ["function", () => {}], + ["callable with toJSON", Object.assign(() => {}, { toJSON: () => ({ query: "hi" }) })], + ])("rejects a %s with UnknownError", async (_label, input) => { const execute = vi.fn(async () => OK); - const input = { - toJSON() { - controller.abort(reason); - return { query: "hi" }; + await expect(runTool(makeTool({ execute }), input as object)).rejects.toMatchObject({ + name: "UnknownError", + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + ["invalid input", null], + ["schema violation", {}], + ["unserializable input", { value: 1n }], + [ + "throwing toJSON", + { + toJSON() { + throw new Error("must not serialize"); + }, }, - }; - await expect(runTool(makeTool({ execute }), input, controller.signal)).rejects.toBe(reason); + ], + ])("checks cancellation before %s", async (_label, input) => { + const reason = new Error("cancelled"); + const execute = vi.fn(async () => OK); + await expect( + runTool(makeTool({ execute }), input as object, AbortSignal.abort(reason)), + ).rejects.toBe(reason); expect(execute).not.toHaveBeenCalled(); }); diff --git a/src/polyfill/execute.ts b/src/polyfill/execute.ts index f515555..fde3685 100644 --- a/src/polyfill/execute.ts +++ b/src/polyfill/execute.ts @@ -10,8 +10,8 @@ function serializeResult(result: unknown): string { } /** - * Execute with JSON-serialized inputs and a per-execution AbortSignal. - * Legacy JSON strings remain supported. Unlike native Chrome, the polyfill + * Execute with object or JSON-string inputs and a per-execution AbortSignal. + * Object inputs are passed by reference. Unlike native Chrome, the polyfill * validates input against inputSchema (OperationError; spec issue #92). */ export function runTool( @@ -19,35 +19,19 @@ export function runTool( inputArguments?: string | object, callerSignal?: AbortSignal, ): Promise { + if (callerSignal?.aborted) { + return Promise.reject(callerSignal.reason); + } + let parsed: unknown; if (typeof inputArguments === "string") { - if (callerSignal?.aborted) { - return Promise.reject(callerSignal.reason); - } try { parsed = JSON.parse(inputArguments); } catch { return Promise.reject(new DOMException("Failed to parse input arguments", "UnknownError")); } } else { - if ( - inputArguments === null || - (typeof inputArguments !== "object" && typeof inputArguments !== "function") - ) { - return Promise.reject(new TypeError("Input arguments must be an object")); - } - try { - const serialized = JSON.stringify(inputArguments); - if (serialized === undefined) { - return Promise.reject(new TypeError("Input arguments are not JSON-serializable")); - } - parsed = JSON.parse(serialized); - } catch (thrown) { - return Promise.reject(thrown); - } - } - if (callerSignal?.aborted) { - return Promise.reject(callerSignal.reason); + parsed = inputArguments; } if (typeof parsed !== "object" || parsed === null) {