Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 18 additions & 12 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
24 changes: 18 additions & 6 deletions examples/native-harness/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
});

Expand Down
64 changes: 43 additions & 21 deletions examples/native-harness/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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<unknown>][] = [
["omitted input", () => executeTool(probe)],
Expand All @@ -162,22 +171,38 @@ async function runSelfTest(log: (line: string) => void) {

const circular: Record<string, unknown> = {};
circular.self = circular;
const nonJsonInputs: [string, object][] = [
["circular", circular],
["BigInt", { value: BigInt(1) }],
["toJSON undefined", { toJSON: () => undefined }],
];
const invalid: [string, () => Promise<unknown>][] = [
["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 {
await run();
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})`,
);
}
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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)})`]),
);
}, []);

Expand Down
40 changes: 36 additions & 4 deletions src/hooks/__tests__/useMcpTool.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StrictMode>
<ToolComponent
config={{ name: "inspect", description: "Inspect input", handler, onSuccess }}
onExecuteRef={executeRef}
/>
</StrictMode>,
);
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();
Expand All @@ -1350,10 +1382,10 @@ describe("consumer object inputs", () => {
/>,
);
const { mc, tool } = await getConsumer();
const input: Record<string, unknown> = {};
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();
Expand Down
8 changes: 5 additions & 3 deletions src/polyfill/__tests__/consumer-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading