diff --git a/src/hooks/__tests__/useMcpTool.test.tsx b/src/hooks/__tests__/useMcpTool.test.tsx index 7f3a76b..0086725 100644 --- a/src/hooks/__tests__/useMcpTool.test.tsx +++ b/src/hooks/__tests__/useMcpTool.test.tsx @@ -139,10 +139,11 @@ describe("registration lifecycle", () => { OK_RESULT, }} /> @@ -162,10 +163,11 @@ describe("registration lifecycle", () => { OK_RESULT, }} /> @@ -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( + + + , + ); + + await waitForRegistration(); + + const mc = document.modelContext; + expect(mc).toBeDefined(); + const spy = vi.spyOn(mc as NonNullable, "registerTool"); + + rerender( + + + , + ); + + 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( { 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( + + + , + ); + + await waitForRegistration(); + + const mc = document.modelContext as NonNullable; + expect(mc).toBeDefined(); + const spy = vi.spyOn(mc, "registerTool"); + + rerender( + + + , + ); + + 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; @@ -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 {state.error?.message ?? ""}; + } + + // Install the polyfill first, then pre-register "dup" directly so the hook's + // own registration rejects with a duplicate-name InvalidStateError. + const { rerender } = render( + + + , + ); + await waitFor(() => expect(document.modelContext).toBeDefined()); + await (document.modelContext as NonNullable).registerTool({ + name: "dup", + description: "incumbent", + execute: () => ({ content: [{ type: "text", text: "x" }] }), + }); + + rerender( + + + , + ); + + 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( + + + , + ); + 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( + + + , + ); + + 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+)", () => { diff --git a/src/hooks/useMcpTool.ts b/src/hooks/useMcpTool.ts index e584324..d875fba 100644 --- a/src/hooks/useMcpTool.ts +++ b/src/hooks/useMcpTool.ts @@ -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, @@ -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(INITIAL_STATE); @@ -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 }), @@ -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); @@ -240,6 +273,8 @@ export function useMcpTool( inputFingerprint, outputFingerprint, annotationsFingerprint, + titleFingerprint, + exposedToFingerprint, ]); return { state, execute, reset }; diff --git a/src/polyfill/__tests__/index.test.ts b/src/polyfill/__tests__/index.test.ts index 5788455..51b2455 100644 --- a/src/polyfill/__tests__/index.test.ts +++ b/src/polyfill/__tests__/index.test.ts @@ -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"; @@ -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; + 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; + 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(); diff --git a/src/polyfill/index.ts b/src/polyfill/index.ts index e576805..0f00498 100644 --- a/src/polyfill/index.ts +++ b/src/polyfill/index.ts @@ -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; @@ -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, diff --git a/src/types.ts b/src/types.ts index f777310..c92ee06 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,15 +63,13 @@ export interface CallToolResult { } export interface ToolAnnotations { - title?: string; readOnlyHint?: boolean; - destructiveHint?: boolean; - idempotentHint?: boolean; - openWorldHint?: boolean; + untrustedContentHint?: boolean; } export interface ToolDescriptor> { name: string; + title?: string; description: string; inputSchema?: InputSchema; outputSchema?: InputSchema; @@ -81,8 +79,10 @@ export interface ToolDescriptor> { interface McpToolConfigBase { name: string; + title?: string; description: string; annotations?: ToolAnnotations; + exposedTo?: string[]; onSuccess?: (result: CallToolResult) => void; onError?: (error: Error) => void; } @@ -131,8 +131,19 @@ export interface RegisterToolOptions { exposedTo?: string[]; } -export interface ModelContext { +export interface ModelContext extends EventTarget { registerTool(tool: ToolDescriptor, options?: RegisterToolOptions): Promise; + ontoolchange: ((this: ModelContext, ev: Event) => unknown) | null; + addEventListener( + type: "toolchange", + listener: (ev: Event) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: "toolchange", + listener: (ev: Event) => unknown, + options?: boolean | EventListenerOptions, + ): void; } export interface ModelContextTestingToolInfo { diff --git a/src/utils/__tests__/schema.test.ts b/src/utils/__tests__/schema.test.ts index 1cba32c..18e732b 100644 --- a/src/utils/__tests__/schema.test.ts +++ b/src/utils/__tests__/schema.test.ts @@ -174,4 +174,10 @@ describe("schemaFingerprint", () => { const b = z.object({ y: z.number() }); expect(schemaFingerprint(a)).not.toBe(schemaFingerprint(b)); }); + + it("does not throw on a circular (non-serializable) schema", () => { + const circular: Record = { type: "object" }; + circular.self = circular; + expect(() => schemaFingerprint(circular as never)).not.toThrow(); + }); }); diff --git a/src/utils/schema.ts b/src/utils/schema.ts index 5c316ee..270aff8 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -20,5 +20,9 @@ export function schemaFingerprint( if (schema instanceof z.ZodObject) { return JSON.stringify(zodToInputSchema(schema)); } - return JSON.stringify(schema); + try { + return JSON.stringify(schema); + } catch { + return "__unserializable__"; + } }