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
174 changes: 171 additions & 3 deletions src/hooks/__tests__/useMcpTool.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,11 @@ describe("registration lifecycle", () => {
<ToolComponent
config={{
name: "greet",
title: "Greeter",
description: "Say hello",
input: z.object({ name: z.string() }),
output: z.object({ greeting: z.string() }),
annotations: { readOnlyHint: true, title: "Greeter" },
annotations: { readOnlyHint: true },
handler: async () => OK_RESULT,
}}
/>
Expand All @@ -162,10 +163,11 @@ describe("registration lifecycle", () => {
<ToolComponent
config={{
name: "greet",
title: "Greeter",
description: "Say hello v2",
input: z.object({ name: z.string() }),
output: z.object({ greeting: z.string() }),
annotations: { readOnlyHint: true, title: "Greeter" },
annotations: { readOnlyHint: true },
handler: async () => OK_RESULT,
}}
/>
Expand All @@ -177,14 +179,49 @@ describe("registration lifecycle", () => {
});

const descriptor = spy.mock.calls[spy.mock.calls.length - 1][0];
expect(descriptor.title).toBe("Greeter");
expect(descriptor.annotations).toEqual({
readOnlyHint: true,
title: "Greeter",
});
expect(descriptor.outputSchema).toBeDefined();
expect(descriptor.outputSchema?.properties?.greeting).toBeDefined();
});

it("forwards top-level title and narrowed annotations to the descriptor", async () => {
function Tool({ description }: { description: string }) {
useMcpTool({
name: "greet",
title: "Greeter",
description,
annotations: { readOnlyHint: true, untrustedContentHint: true },
handler: () => ({ content: [{ type: "text", text: "hi" }] }),
});
return null;
}
const { rerender } = render(
<WebMCPProvider name="t" version="1">
<Tool description="greets" />
</WebMCPProvider>,
);

await waitForRegistration();

const mc = document.modelContext;
expect(mc).toBeDefined();
const spy = vi.spyOn(mc as NonNullable<typeof mc>, "registerTool");

rerender(
<WebMCPProvider name="t" version="1">
<Tool description="greets v2" />
</WebMCPProvider>,
);

await waitFor(() => expect(spy).toHaveBeenCalled());
const descriptor = spy.mock.calls[0][0];
expect(descriptor.title).toBe("Greeter");
expect(descriptor.annotations).toEqual({ readOnlyHint: true, untrustedContentHint: true });
});

it("removes tool on unmount", async () => {
const { unmount } = renderWithProvider(
<ToolComponent
Expand All @@ -208,6 +245,38 @@ describe("registration lifecycle", () => {
await waitFor(() => expect(navigator.modelContextTesting?.listTools() ?? []).toHaveLength(0));
});

it("forwards exposedTo and re-registers when it changes", async () => {
function Tool({ origins }: { origins: string[] }) {
useMcpTool({
name: "scoped",
description: "scoped tool",
exposedTo: origins,
handler: () => ({ content: [{ type: "text", text: "hi" }] }),
});
return null;
}
const { rerender } = render(
<WebMCPProvider name="t" version="1">
<Tool origins={["https://a.example"]} />
</WebMCPProvider>,
);

await waitForRegistration();

const mc = document.modelContext as NonNullable<typeof document.modelContext>;
expect(mc).toBeDefined();
const spy = vi.spyOn(mc, "registerTool");

rerender(
<WebMCPProvider name="t" version="1">
<Tool origins={["https://b.example"]} />
</WebMCPProvider>,
);

await waitFor(() => expect(spy).toHaveBeenCalled());
expect(spy.mock.calls.at(-1)?.[1]?.exposedTo).toEqual(["https://b.example"]);
});

it("re-registers when description changes", async () => {
const executeRef = { current: null } as React.MutableRefObject<ExecuteFn | null>;

Expand Down Expand Up @@ -1082,6 +1151,105 @@ describe("provider warning", () => {
});
});

// ─── Registration error routing + ownership ──────────────────────

describe("registration error routing + ownership", () => {
it("routes a registration rejection into state.error and onError", async () => {
const onError = vi.fn();
function Tool() {
const { state } = useMcpTool({
name: "dup",
description: "d",
onError,
handler: () => ({ content: [{ type: "text", text: "ok" }] }),
});
return <span data-testid="err">{state.error?.message ?? ""}</span>;
}

// Install the polyfill first, then pre-register "dup" directly so the hook's
// own registration rejects with a duplicate-name InvalidStateError.
const { rerender } = render(
<WebMCPProvider name="t" version="1">
<span />
</WebMCPProvider>,
);
await waitFor(() => expect(document.modelContext).toBeDefined());
await (document.modelContext as NonNullable<typeof document.modelContext>).registerTool({
name: "dup",
description: "incumbent",
execute: () => ({ content: [{ type: "text", text: "x" }] }),
});

rerender(
<WebMCPProvider name="t" version="1">
<Tool />
</WebMCPProvider>,
);

await waitFor(() => expect(onError).toHaveBeenCalled());
expect(onError.mock.calls[0][0].name).toBe("InvalidStateError");
await waitFor(() =>
expect(document.querySelector("[data-testid='err']")?.textContent).not.toBe(""),
);
});

it("does NOT route AbortError (lifecycle teardown) into onError", async () => {
const onError = vi.fn();
function Tool() {
useMcpTool({
name: "abortable",
description: "d",
onError,
handler: () => ({ content: [{ type: "text", text: "ok" }] }),
});
return null;
}
const { unmount } = render(
<WebMCPProvider name="t" version="1">
<Tool />
</WebMCPProvider>,
);
await waitFor(() => expect(navigator.modelContextTesting?.listTools() ?? []).toHaveLength(1));
unmount();
await act(async () => {});
expect(onError).not.toHaveBeenCalled();
});

it("duplicate-name loser does not evict the winner's registration", async () => {
const winnerError = vi.fn();
const loserError = vi.fn();
function Pair() {
useMcpTool({
name: "shared",
description: "winner",
onError: winnerError,
handler: () => ({ content: [{ type: "text", text: "winner" }] }),
});
useMcpTool({
name: "shared",
description: "loser",
onError: loserError,
handler: () => ({ content: [{ type: "text", text: "loser" }] }),
});
return null;
}

render(
<WebMCPProvider name="t" version="1">
<Pair />
</WebMCPProvider>,
);

await waitFor(() => expect(loserError).toHaveBeenCalled());
expect(loserError.mock.calls[0][0].name).toBe("InvalidStateError");

// Winner stays registered; the loser never evicted it.
const tools = navigator.modelContextTesting?.listTools() ?? [];
expect(tools.filter((t) => t.name === "shared")).toHaveLength(1);
expect(winnerError).not.toHaveBeenCalled();
});
});

// ─── Signal-only native API (Chrome 148+) ────────────────────────

describe("signal-only native API (Chrome 148+)", () => {
Expand Down
51 changes: 43 additions & 8 deletions src/hooks/useMcpTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@ export function _resetToolOwners(): void {
TOOL_OWNER_BY_NAME.clear();
}

// Normalize a thrown value into an Error while preserving its `name`.
// registerTool rejects with DOMException, which is not `instanceof Error` in
// every environment (e.g. jsdom); a naive `new Error(String(thrown))` would
// drop the `name` (e.g. "InvalidStateError", "AbortError") that callers route on.
function normalizeError(thrown: unknown): Error {
if (thrown instanceof Error) return thrown;
if (
typeof thrown === "object" &&
thrown !== null &&
"name" in thrown &&
typeof (thrown as { name: unknown }).name === "string"
) {
const source = thrown as { name: string; message?: unknown };
const error = new Error(typeof source.message === "string" ? source.message : String(thrown));
error.name = source.name;
return error;
}
return new Error(String(thrown));
}

const INITIAL_STATE: ToolExecutionState = {
isExecuting: false,
lastResult: null,
Expand Down Expand Up @@ -54,6 +74,8 @@ export function useMcpTool(
: (config as McpToolConfigJsonSchema).outputSchema,
);
const annotationsFingerprint = config.annotations ? JSON.stringify(config.annotations) : "";
const titleFingerprint = config.title ?? "";
const exposedToFingerprint = config.exposedTo ? JSON.stringify(config.exposedTo) : "";

const [state, setState] = useState<ToolExecutionState>(INITIAL_STATE);

Expand Down Expand Up @@ -155,6 +177,7 @@ export function useMcpTool(

const descriptor: ToolDescriptor = {
name: cfg.name,
...(cfg.title && { title: cfg.title }),
description: cfg.description,
...(resolvedInputSchema && { inputSchema: resolvedInputSchema }),
...(resolvedOutputSchema && { outputSchema: resolvedOutputSchema }),
Expand Down Expand Up @@ -216,18 +239,28 @@ export function useMcpTool(
};

const controller = new AbortController();
const alreadyOwned = TOOL_OWNER_BY_NAME.has(cfg.name);
if (!alreadyOwned) {
TOOL_OWNER_BY_NAME.set(cfg.name, ownerToken);
}

mc.registerTool(descriptor, { signal: controller.signal }).catch((err) => {
warnOnce(
`register-${cfg.name}`,
`Failed to register tool "${cfg.name}": ${err instanceof Error ? err.message : String(err)}`,
);
mc.registerTool(descriptor, {
signal: controller.signal,
...(cfg.exposedTo && { exposedTo: cfg.exposedTo }),
}).catch((thrown: unknown) => {
const error = normalizeError(thrown);
if (error.name === "AbortError") return; // lifecycle teardown — not a user error
if (TOOL_OWNER_BY_NAME.get(cfg.name) === ownerToken) {
TOOL_OWNER_BY_NAME.delete(cfg.name);
}
if (isMountedRef.current) {
setState((prev) => ({ ...prev, error }));
}
onErrorRef.current?.(error);
});
TOOL_OWNER_BY_NAME.set(cfg.name, ownerToken);

return () => {
const currentOwner = TOOL_OWNER_BY_NAME.get(cfg.name);
if (currentOwner !== ownerToken) {
if (TOOL_OWNER_BY_NAME.get(cfg.name) !== ownerToken) {
return;
}
TOOL_OWNER_BY_NAME.delete(cfg.name);
Expand All @@ -240,6 +273,8 @@ export function useMcpTool(
inputFingerprint,
outputFingerprint,
annotationsFingerprint,
titleFingerprint,
exposedToFingerprint,
]);

return { state, execute, reset };
Expand Down
31 changes: 30 additions & 1 deletion src/polyfill/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToolDescriptor } from "../../types";
import { cleanupPolyfill, installPolyfill } from "../index";

Expand Down Expand Up @@ -147,6 +147,35 @@ describe("installPolyfill / cleanupPolyfill", () => {
expect(document.modelContext).toBeUndefined();
});

it("dispatches toolchange when a tool is registered (addEventListener)", async () => {
installPolyfill();
const mc = document.modelContext as NonNullable<typeof document.modelContext>;
const seen = vi.fn();
mc.addEventListener("toolchange", seen);
await mc.registerTool({
name: "evt_tool",
description: "d",
execute: () => ({ content: [{ type: "text", text: "ok" }] }),
});
await Promise.resolve();
expect(seen).toHaveBeenCalledTimes(1);
expect(seen.mock.calls[0][0]).toBeInstanceOf(Event);
});

it("invokes the ontoolchange handler", async () => {
installPolyfill();
const mc = document.modelContext as NonNullable<typeof document.modelContext>;
const handler = vi.fn();
mc.ontoolchange = handler;
await mc.registerTool({
name: "evt_tool2",
description: "d",
execute: () => ({ content: [{ type: "text", text: "ok" }] }),
});
await Promise.resolve();
expect(handler).toHaveBeenCalledTimes(1);
});

it("full lifecycle: install → register tool → execute via testing shim → cleanup", async () => {
installPolyfill();

Expand Down
29 changes: 22 additions & 7 deletions src/polyfill/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
import type { ModelContext } from "../types";
import { createRegistry } from "./registry";
import { createRegistry, type RegistryInternal } from "./registry";
import { createTestingShim } from "./testing-shim";

interface PolyfillModelContext extends ModelContext {
__isWebMCPPolyfill: true;
class PolyfillModelContext extends EventTarget {
readonly __isWebMCPPolyfill = true as const;
registerTool: ModelContext["registerTool"];
#ontoolchange: ((ev: Event) => unknown) | null = null;

constructor(registry: RegistryInternal) {
super();
this.registerTool = registry.registerTool;
}

get ontoolchange(): ((ev: Event) => unknown) | null {
return this.#ontoolchange;
}

set ontoolchange(handler: ((ev: Event) => unknown) | null) {
if (this.#ontoolchange) this.removeEventListener("toolchange", this.#ontoolchange);
this.#ontoolchange = handler;
if (handler) this.addEventListener("toolchange", handler);
}
}

let installed = false;
Expand All @@ -25,10 +42,8 @@ export function installPolyfill(): void {
const registry = createRegistry();
const testingShim = createTestingShim(registry);

const modelContext: PolyfillModelContext = {
registerTool: registry.registerTool,
__isWebMCPPolyfill: true,
};
const modelContext = new PolyfillModelContext(registry);
registry.addChangeListener(() => modelContext.dispatchEvent(new Event("toolchange")));

Object.defineProperty(document, "modelContext", {
value: modelContext,
Expand Down
Loading
Loading