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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 6 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion examples/native-harness/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand All @@ -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");
});

Expand Down
19 changes: 17 additions & 2 deletions examples/native-harness/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,26 @@ async function runSelfTest(log: (line: string) => void) {
: "FAIL: object input serialization or cloning",
);

const defaults: [string, () => Promise<unknown>][] = [
["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<string, unknown> = {};
circular.self = circular;
const invalid: [string, () => Promise<unknown>][] = [
["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) })],
Expand Down
209 changes: 209 additions & 0 deletions extension/src/__tests__/content-main.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof window.addEventListener>;

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<NonNullable<PageModelContext["executeTool"]>>(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);
});
23 changes: 17 additions & 6 deletions extension/src/content-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading