From e3873834e249e531db3b1da12d4397f69a8a242d Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 18:10:54 +0200 Subject: [PATCH 01/12] docs: add design spec for WebMCP signal threading + consumer API migration --- ...1-webmcp-signal-and-consumer-api-design.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-21-webmcp-signal-and-consumer-api-design.md diff --git a/docs/superpowers/specs/2026-08-21-webmcp-signal-and-consumer-api-design.md b/docs/superpowers/specs/2026-08-21-webmcp-signal-and-consumer-api-design.md new file mode 100644 index 0000000..2cd593b --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-webmcp-signal-and-consumer-api-design.md @@ -0,0 +1,248 @@ +# WebMCP AbortSignal threading + consumer API migration — design + +**Date:** 2026-08-21 +**Status:** Approved (pending final spec review) +**Drivers:** Chrome EPP announcements of 2026-08-18 ("ModelContextTool execute function now +receives AbortSignal") and 2026-08-20 ("RegisteredTool inputSchema type change"), plus the +Chrome 152 removal of `navigator.modelContextTesting` discovered while verifying them. + +## Background + +Chrome's WebMCP implementation moved under the library in four steps: + +| Chrome | Change | +|---|---| +| 152.0.7940.0 | `navigator.modelContextTesting` **removed**. Consumer API is now `document.modelContext.getTools()` / `executeTool(tool, inputArguments, { signal })`. | +| 153.0.8007.0 | Tool `execute` is now always called as `execute(input, { signal })` — `ToolExecuteCallbackOptions { required AbortSignal signal }`, fresh signal per execution. Tool-side abort reason is always a generic `AbortError` DOMException; the caller's custom reason is not forwarded. (Spec PR webmachinelearning/webmcp#247, issue #48.) | +| 153.0.8008.0 | Unregistering a tool no longer cancels in-flight executions; they run to completion and the caller receives the result. (Spec issue #218.) | +| 154.0.8014.0 | `RegisteredTool.inputSchema` returned by `getTools()` changed from a JSON string to a JavaScript object (deep copy of the registered schema). (Spec PR #241.) | + +Impact on this repo today: + +- `useMcpTool` handlers never see the execution signal, so user code cannot cancel work + (e.g. `fetch`) when the agent aborts a call. +- The polyfill exposes no `getTools()`/`executeTool()`; its consumer surface is + `navigator.modelContextTesting`, which native Chrome deleted. On Chrome 152+ the bridge + extension cannot discover or execute tools at all. +- `extension/src/content-main.ts`, `examples/native-harness`, `examples/playground` + (DevPanel), and the docs all target `navigator.modelContextTesting`. + +Nothing is *broken* by the 153 signal change itself (extra call arguments are ignored in JS), +and the unregister change is favorable to the hook's existing unmount handling. The work is: +thread the signal through, and migrate the consumer surface. + +## Scope and PR split + +Two PRs: + +1. **PR 1 — library (`src/` + docs).** Signal threading in types/hook, polyfill + `getTools()`/`executeTool()`, shim deprecation, tests, docs. Released as **0.3.0**. +2. **PR 2 — extension + examples.** Migrate to `document.modelContext` with + `modelContextTesting` fallback, wire end-to-end MCP cancellation, update + native-harness and playground. No npm release. + +## Decisions (with rationale) + +- **Abort is cancellation, not error.** When an execution's signal is aborted and the handler + rejects, the hook decrements in-flight state but does not set `state.error` or fire + `onError`. Mirrors the existing treatment of `AbortError` from `registerTool` as lifecycle + teardown; Chrome discards the result anyway. +- **Handlers always get a real `AbortSignal`.** On Chrome ≤152 / bare `execute(args)` calls / + third-party polyfills, the hook substitutes `new AbortController().signal` (never aborts), + so user code can pass `signal` to `fetch` unconditionally. +- **Polyfill targets the newest shipped Chrome shape.** `getTools()` returns `inputSchema` + as a deep-copied object (Chrome 154+/spec), not the ≤153 string. String-vs-object is a + consumer compat concern, handled at consumption sites. +- **Keep the polyfill's input validation.** Native Chrome does not validate `executeTool` + input against `inputSchema` (spec issue #92); our polyfill keeps `validateArgs` and its + `OperationError` for dev-time safety. Documented as stricter-than-native. +- **Deprecate `navigator.modelContextTesting`, keep it one release.** In 0.3.0 the shim + becomes a thin wrapper over the new engine with a `warnOnce` deprecation; removal targeted + for 0.4.0. The extension prefers `document.modelContext` and falls back to the shim so + pages on webmcp-react 0.2.0 keep working. +- **Extension E2E cancellation ships in PR 2**, as its own commits after the migration + commits, so the migration remains independently revertable. + +## §1 Library public API (PR 1) + +New exported types in `src/types.ts`: + +```ts +export interface ToolExecuteCallbackOptions { signal: AbortSignal } + +export interface RegisteredTool { + name: string; + title?: string; // "" default from the polyfill, matching Chrome + description: string; + inputSchema?: InputSchema | string; // object on Chrome 154+/polyfill, string on ≤153 + annotations?: ToolAnnotations; + window?: Window; + origin?: string; +} + +export interface ModelContextGetToolOptions { fromOrigins?: string[] } +export interface ExecuteToolOptions { signal?: AbortSignal } +``` + +Changed signatures (additive — one-param functions remain assignable): + +- `ToolDescriptor.execute: (input, options: ToolExecuteCallbackOptions) => MaybePromise` +- `McpToolConfigZod.handler: (args, ctx: ToolExecuteCallbackOptions) => …` +- `McpToolConfigJsonSchema.handler: (args, ctx: ToolExecuteCallbackOptions) => …` +- `UseMcpToolReturn.execute: (input?, options?: { signal?: AbortSignal }) => Promise` + +`ModelContext` interface gains: + +- `getTools(options?: ModelContextGetToolOptions): Promise` +- `executeTool(tool: RegisteredTool, inputArguments: string | object, options?: ExecuteToolOptions): Promise` + +`ModelContextTesting*` types remain with `@deprecated` JSDoc. `index.ts` re-exports the new +types (`ToolExecuteCallbackOptions`, `RegisteredTool`, `ModelContextGetToolOptions`, +`ExecuteToolOptions`); no other public-surface change. + +## §2 Hook behavior (`useMcpTool.ts`, PR 1) + +Both execution paths share one internal `runHandler(args, signal, { throwOnError })`: + +- **Descriptor `execute(args, options?)`** (agent path): + `const signal = options?.signal ?? new AbortController().signal`; Zod-parse if applicable; + `handler(validatedArgs, { signal })`. Success path unchanged. Failure with + `signal.aborted` → cancellation: decrement in-flight, recompute `isExecuting`, leave + `error`/`lastResult` untouched, skip `onError`, return the usual + `{ content, isError: true }` result (resolving avoids Chrome's console error for + post-abort rejections). Failure without abort → unchanged error path. +- **Direct `execute(input?, { signal }?)`** (UI path): same, but failures rethrow as today; + an aborted-signal failure still rethrows to the caller yet leaves `state.error`/`onError` + untouched. +- **Unmount/re-register:** unchanged — the hook aborts only the registration signal, never + execution signals. Under Chrome 153.0.8008+ in-flight executions complete after + unregistration; `isMountedRef` guards setState. Documented explicitly. +- **Edge (accepted):** a handler that ignores the signal and resolves after abort runs the + normal success path locally; Chrome ignores the late result. + +## §3 Polyfill consumer API (`src/polyfill/`, PR 1) + +**New `polyfill/execute.ts`** — single execution engine used by `executeTool` and the shim: + +``` +runTool(tool: ToolDescriptor, inputArguments: string | object, callerSignal?: AbortSignal): Promise +``` + +1. Pre-aborted caller signal → reject with `callerSignal.reason`. +2. Parse input: string → `JSON.parse` (failure → `UnknownError`); object → as-is; + non-object parse result → `UnknownError`. +3. Validate against `tool.inputSchema` via existing `validateArgs` → `OperationError`. +4. Fresh `AbortController` per execution; `tool.execute(parsed, { signal })`. +5. Caller abort mid-flight: abort the controller with the default reason (generic + `AbortError`, matching Chrome), reject caller with `callerSignal.reason`, ignore late + settlement (no unhandled rejection). +6. Tool rejects (not aborted) → `UnknownError` with message. Tool resolves → objects + `JSON.stringify` (throw → `UnknownError`), primitives `String()`, empty string → + `"Operation succeeded"`. +7. Unregistration mid-flight does not affect the execution (tool captured up front). + +**`PolyfillModelContext`** gains: + +- `getTools(options?)` → fresh plain objects per call, sorted by name: + `{ name, title: title ?? "", description, inputSchema: JSON.parse(JSON.stringify(schema)) | undefined, // JSON round-trip, matching how Chrome materializes the object + annotations?, window, origin: location.origin }`. `fromOrigins` entries validated + (`SecurityError` if not potentially trustworthy) but otherwise a no-op — the polyfill is + same-document only (documented). +- `executeTool(tool, inputArguments, options?)` → lookup by `tool.name` + (unknown/stale → `UnknownError`), delegate to `runTool`. Returns `Promise` + (typed `string | null` for native parity). + +**Registry:** unchanged except `RegistryInternal.get(name)`. + +**`testing-shim.ts`:** thin deprecated wrapper — `listTools()` maps registry tools with +JSON-string `inputSchema` (its historical shape); `executeTool(name, json, opts)` finds the +tool and calls `runTool`; first use fires a `warnOnce` deprecation pointing at +`document.modelContext.getTools()/executeTool()`. Still installed on +`navigator.modelContextTesting` in 0.3.0; removal in 0.4.0. + +**Not doing:** `toolactivated`/`toolcancel` window events (Chromium-only, unspecced, no +consumer), cross-frame discovery, `exposedTo` filtering in `getTools` (single-document +polyfill — every registered tool is same-origin by construction). + +## §4 Extension + examples (PR 2) + +**`content-main.ts` — `PageToolApi` adapter,** selected at detection time (polling retained; +each tick prefers modelContext): + +- **modelContext adapter** (native Chrome 150+ or 0.3.0 polyfill; detected via + `typeof document.modelContext?.getTools === "function"`): + `list()` = `getTools()` → `BrowserTool[]`, normalizing `inputSchema` with + `typeof s === "string" ? s : JSON.stringify(s)` (string on ≤153, object on 154+). + `execute(name, argsJson, signal)` = `getTools()` → find by name → + `executeTool(tool, argsJson, { signal })`; on `TypeError` (future object-only Chrome), + retry once with `JSON.parse(argsJson)`. `onChange` = `toolchange` listener. +- **modelContextTesting adapter** — fallback for 0.2.0 pages / Chrome ≤151; as today plus + `{ signal }` (the 0.2.0 shim honors it). + +**Cancellation plumbing** — new `CANCEL_TOOL { requestId }` message per hop, mirroring the +`EXECUTE_TOOL` family: + +1. `mcp-server/index.ts`: pass the MCP SDK's `extra.signal` into + `registry.callTool(name, args, signal)`. +2. `tool-registry.ts`: on abort → send WS `CANCEL_TOOL`, drop the pending entry, reject with + `AbortError`. +3. `background.ts`: `CANCEL_TOOL` → resolve pending request's tab → + `chrome.tabs.sendMessage(tabId, { type: "CANCEL_TOOL", requestId })`. +4. `content-isolated.ts` → `postMessage({ type: "WEBMCP_CANCEL_TOOL", requestId })`. +5. `content-main.ts`: `Map`; cancel → `abort()`. The resulting + error still posts `WEBMCP_TOOL_RESULT`, dropped upstream because the requestId is no + longer pending (verify "unknown requestId → ignore" exists at each hop; add if missing). + +**`extension/src/types.ts`:** add the `*CancelTool*` message types; add local +`ModelContext`/`RegisteredTool` declarations (extension keeps standalone declarations, as +today); mark `ModelContextTesting` deprecated. + +**Examples:** + +- `native-harness/App.tsx`: probes move to `document.modelContext.getTools()/executeTool()`; + add probes for (a) handler receives an `AbortSignal`, (b) caller abort → handler signal + aborts with `AbortError` and caller rejects with the caller's reason, (c) unregister + mid-flight → still resolves (153.0.8008+), (d) `inputSchema` string-or-object handling. +- `playground/DevPanel.tsx`: `getTools()/executeTool()`; Cancel button while in flight; add + a `slow_hint` demo tool (~3s, honors `signal`) so cancellation is demonstrable. +- `extension/README.md`, `PRIVACY.md`: modelContextTesting → document.modelContext. + +## §5 Testing, docs, versioning + +**Tests (PR 1)** — existing `__tests__` layout, StrictMode-compatible: + +- Hook: real `AbortSignal` on both paths; bare `execute(args)` (Chrome ≤152 shape) yields a + non-aborting signal; caller abort → handler signal `AbortError`, caller sees caller's + reason, `state.error` null, no `onError`; non-abort failures unchanged; registration-signal + abort does not abort execution signals. +- Polyfill: `getTools()` sorted / fresh objects / deep-copied object `inputSchema` / + `title: ""` default / `fromOrigins` validation; `executeTool()` serialization matrix + (object → JSON, primitives, empty string → "Operation succeeded"), unknown tool → + `UnknownError`, invalid JSON → `UnknownError`, schema violation → `OperationError`, + pre-aborted → `signal.reason`, mid-flight abort (tool signal aborts, caller rejects, late + settlement ignored), unregister mid-flight → resolves. +- Shim: existing tests pass except two error-shape updates (pre-aborted and mid-flight abort + now reject with `signal.reason`), updated with comments citing Chrome 152+; deprecation + `warnOnce` fires once. +- Types: compile-time check that one-arg handlers still typecheck. + +**Extension verification (PR 2):** no test harness exists in `extension/`; verified via the +native-harness probes against Chrome canary and the polyfill, documented in the PR. Adding a +test framework to `extension/` is out of scope. + +**Docs:** README (0.3.0 changes section: handler second arg, consumer API, deprecation), +docs/api.md (getTools/executeTool section + Chrome compat table: ≤152 no tool signal / 153 +signal / 154 object `inputSchema`), AGENTS.md ("Single-arg execute/handler" → two-arg; +consumer-API note), skills, CHANGELOG (per-PR entries). + +**Versioning:** 0.3.0 cut after PR 1. `navigator.modelContextTesting` deprecated in 0.3.0, +removed in 0.4.0. + +## Risks + +- **Chrome may flip `executeTool` input to object-only** (open CL). Mitigated: adapter + retries with a parsed object on `TypeError`; polyfill accepts both already. +- **Spec/Chromium drift** (spec PR #247 unmerged; event names unsettled). Mitigated: we track + shipped Chrome behavior and note spec status in docs; events deliberately not implemented. +- **Silent breakage for 0.2.0 pages with the new extension** if the fallback path regresses — + covered by keeping the modelContextTesting adapter until 0.4.0. From 7a378423a97db5337ebcee23a668afb19035f2cc Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 18:27:18 +0200 Subject: [PATCH 02/12] docs: add implementation plan for WebMCP signal + consumer API work --- ...26-08-21-webmcp-signal-and-consumer-api.md | 2343 +++++++++++++++++ 1 file changed, 2343 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-webmcp-signal-and-consumer-api.md diff --git a/docs/superpowers/plans/2026-08-21-webmcp-signal-and-consumer-api.md b/docs/superpowers/plans/2026-08-21-webmcp-signal-and-consumer-api.md new file mode 100644 index 0000000..b3d9519 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-webmcp-signal-and-consumer-api.md @@ -0,0 +1,2343 @@ +# WebMCP AbortSignal Threading + Consumer API Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Thread the Chrome 153 execution `AbortSignal` through to `useMcpTool` handlers, add the Chrome 152+ consumer API (`document.modelContext.getTools()`/`executeTool()`) to the polyfill, and migrate the bridge extension + examples off the removed `navigator.modelContextTesting` — with end-to-end MCP cancellation. + +**Architecture:** Two PRs. PR 1 (Tasks 1–6) touches only `src/` + docs and ships as webmcp-react 0.3.0: new types, hook signal threading with abort-as-cancellation semantics, a single execution engine (`src/polyfill/execute.ts`) used by both the new `document.modelContext.executeTool()` and the now-deprecated testing shim. PR 2 (Tasks 7–11) migrates `extension/` and `examples/` to the consumer API behind a small `PageToolApi` adapter (with a `modelContextTesting` fallback for 0.2.0 pages) and wires a `CANCEL_TOOL` message through all four hops (MCP SDK → WebSocket → background → content scripts → page). + +**Tech Stack:** TypeScript, React 18/19, Zod, Vitest + React Testing Library + jsdom, Biome, tsup, pnpm workspaces, Chrome extension MV3, `@modelcontextprotocol/sdk`. + +**Spec:** `docs/superpowers/specs/2026-08-21-webmcp-signal-and-consumer-api-design.md` (approved). Read it before starting. + +## Global Constraints + +- Package manager is **pnpm**. Full check before each PR: `pnpm build && pnpm typecheck && pnpm lint && pnpm test`. +- Biome pre-commit hook auto-formats staged files; never hand-tune formatting — run `pnpm lint:fix` if needed. +- **No new dependencies** anywhere (AGENTS.md boundary). +- **Never delete or weaken existing tests.** Where shipped-Chrome behavior changed an error shape, update the assertion, keep the test's intent, and add a comment citing the Chrome version (e.g. "Chrome 152+ rejects with signal.reason"). +- **Both execution paths stay mirrored** (AGENTS.md): direct `execute()` and the agent path must share behavior via a single code path. +- All `src/` tests must pass under React StrictMode (double-mount). +- Do NOT add `"use client"` to source files (added at build time by tsup). +- Error names match shipped Chrome: execution failures reject `UnknownError`; polyfill-only schema validation keeps `OperationError`; unknown tool via shim keeps `NotFoundError`; unknown tool via `executeTool` uses `UnknownError`. +- Version: PR 1 bumps `package.json` to **0.3.0**. `navigator.modelContextTesting` is deprecated in 0.3.0 and removed in 0.4.0 (do NOT remove it now). +- Handlers must **always** receive a real `AbortSignal`: substitute `new AbortController().signal` when the caller provides none (Chrome ≤152, bare `execute(args)`, third-party polyfills). +- Abort semantics: when an execution's signal is aborted and the handler rejects, that is **cancellation** — decrement in-flight state, do NOT set `state.error`, do NOT fire `onError`. +- Branches/PRs: PR 1 = current branch `kashish/package-email-support-922aa3` → `main`. PR 2 = branch `kashish/webmcp-extension-modelcontext` created from PR 1's head, PR based on PR 1's branch (retarget to `main` after PR 1 merges). +- Commit messages follow existing history style: `feat: …`, `docs: …`, `chore: …`, `test: …`. + +--- + +## PR 1 — Library (`src/` + docs), ships as 0.3.0 + +### Task 1: Types — `ToolExecuteCallbackOptions`, `RegisteredTool`, consumer-API surface + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/index.ts` +- Test: `src/__tests__/type-compat.test.ts` (create) + +**Interfaces:** +- Consumes: existing `InputSchema`, `ToolAnnotations`, `CallToolResult`, `MaybePromise` from `src/types.ts`. +- Produces (later tasks rely on these exact names): + - `ToolExecuteCallbackOptions { signal: AbortSignal }` + - `ExecuteToolOptions { signal?: AbortSignal }` + - `RegisteredTool { name: string; title?: string; description: string; inputSchema?: InputSchema | string; annotations?: ToolAnnotations; window?: Window; origin?: string }` + - `ModelContextGetToolOptions { fromOrigins?: string[] }` + - `ToolDescriptor.execute(input: TArgs, options: ToolExecuteCallbackOptions): MaybePromise` + - `handler(args, ctx: ToolExecuteCallbackOptions)` on both config types + - `UseMcpToolReturn.execute(input?: Record, options?: ExecuteToolOptions): Promise` + - `ModelContext.getTools?(options?: ModelContextGetToolOptions): Promise` + - `ModelContext.executeTool?(tool: RegisteredTool, inputArguments: string | object, options?: ExecuteToolOptions): Promise` + +- [ ] **Step 1: Write the failing type-compat test** + +Create `src/__tests__/type-compat.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { + ExecuteToolOptions, + McpToolConfigJsonSchema, + McpToolConfigZod, + RegisteredTool, + ToolExecuteCallbackOptions, +} from "../types"; + +// Compile-time assertions. One-argument handlers must remain assignable after +// the handler signature gains a second (ctx) parameter, and the new consumer +// API types must accept both the Chrome ≤153 (string) and 154+ (object) +// inputSchema shapes. + +const oneArgJson: McpToolConfigJsonSchema = { + name: "one_arg", + description: "legacy single-arg handler", + handler: async (args) => ({ + content: [{ type: "text", text: String(Object.keys(args).length) }], + }), +}; + +const twoArgJson: McpToolConfigJsonSchema = { + name: "two_arg", + description: "signal-aware handler", + handler: async (_args, ctx: ToolExecuteCallbackOptions) => { + ctx.signal.throwIfAborted(); + return { content: [{ type: "text", text: "ok" }] }; + }, +}; + +const oneArgZod: McpToolConfigZod<{ q: z.ZodString }> = { + name: "zod_one", + description: "legacy single-arg zod handler", + input: z.object({ q: z.string() }), + handler: async ({ q }) => ({ content: [{ type: "text", text: q }] }), +}; + +const twoArgZod: McpToolConfigZod<{ q: z.ZodString }> = { + name: "zod_two", + description: "signal-aware zod handler", + input: z.object({ q: z.string() }), + handler: async ({ q }, { signal }) => { + signal.throwIfAborted(); + return { content: [{ type: "text", text: q }] }; + }, +}; + +const objectSchema: RegisteredTool = { + name: "t1", + description: "d", + inputSchema: { type: "object", properties: {} }, +}; + +const stringSchema: RegisteredTool = { + name: "t2", + description: "d", + inputSchema: '{"type":"object"}', +}; + +const opts: ExecuteToolOptions = { signal: new AbortController().signal }; + +describe("type compatibility", () => { + it("compiles", () => { + expect([oneArgJson, twoArgJson, oneArgZod, twoArgZod, objectSchema, stringSchema, opts]).toHaveLength(7); + }); +}); +``` + +- [ ] **Step 2: Run typecheck to verify it fails** + +Run: `pnpm typecheck` +Expected: FAIL — `ToolExecuteCallbackOptions`, `RegisteredTool`, `ExecuteToolOptions` are not exported from `../types`, and the two-arg handlers don't match the current one-arg handler types. + +- [ ] **Step 3: Update `src/types.ts`** + +Add after the `MaybePromise` definition (near the top): + +```ts +/** Second argument to a tool's execute callback / useMcpTool handler (Chrome 153+ shape). */ +export interface ToolExecuteCallbackOptions { + signal: AbortSignal; +} + +/** Options for ModelContext.executeTool and UseMcpToolReturn.execute. */ +export interface ExecuteToolOptions { + signal?: AbortSignal; +} +``` + +Change `ToolDescriptor.execute` (currently `execute: (input: TArgs) => MaybePromise;`): + +```ts + execute: (input: TArgs, options: ToolExecuteCallbackOptions) => MaybePromise; +``` + +Change the two handler signatures: + +```ts +// In McpToolConfigZod: + handler: ( + args: z.infer>, + ctx: ToolExecuteCallbackOptions, + ) => MaybePromise; + +// In McpToolConfigJsonSchema: + handler: ( + args: Record, + ctx: ToolExecuteCallbackOptions, + ) => MaybePromise; +``` + +Change `UseMcpToolReturn.execute`: + +```ts + execute: (input?: Record, options?: ExecuteToolOptions) => Promise; +``` + +Add before the `ModelContext` interface: + +```ts +/** + * Tool metadata returned by ModelContext.getTools(). + * `inputSchema` is an object on Chrome 154+ and this library's polyfill, but a + * JSON string on Chrome ≤153 — consumers must handle both: + * `typeof s === "string" ? JSON.parse(s) : s`. + */ +export interface RegisteredTool { + name: string; + title?: string; + description: string; + inputSchema?: InputSchema | string; + annotations?: ToolAnnotations; + window?: Window; + origin?: string; +} + +export interface ModelContextGetToolOptions { + fromOrigins?: string[]; +} +``` + +Add to the `ModelContext` interface body (after `registerTool`). Optional because native Chrome ≤149 and third-party polyfills expose `registerTool` without the consumer API: + +```ts + getTools?(options?: ModelContextGetToolOptions): Promise; + executeTool?( + tool: RegisteredTool, + inputArguments: string | object, + options?: ExecuteToolOptions, + ): Promise; +``` + +Add `@deprecated` JSDoc to the three testing types (keep them intact otherwise): + +```ts +/** @deprecated Removed from native Chrome in 152; use ModelContext.getTools()/executeTool(). Will be removed in webmcp-react 0.4.0. */ +export interface ModelContextTestingToolInfo { ... } + +/** @deprecated Removed from native Chrome in 152. Will be removed in webmcp-react 0.4.0. */ +export interface ModelContextTestingExecuteToolOptions { ... } + +/** @deprecated Removed from native Chrome in 152; use document.modelContext.getTools()/executeTool(). Will be removed in webmcp-react 0.4.0. */ +export interface ModelContextTesting { ... } +``` + +- [ ] **Step 4: Export the new types from `src/index.ts`** + +Add to the existing `export type` block: `ExecuteToolOptions`, `ModelContextGetToolOptions`, `RegisteredTool`, `ToolExecuteCallbackOptions` (keep alphabetical order within the block). + +- [ ] **Step 5: Run typecheck and tests** + +Run: `pnpm typecheck && pnpm test -- src/__tests__/type-compat.test.ts` +Expected: typecheck PASS (the hook still compiles because one-arg implementations are assignable to two-arg types); type-compat test PASS. If `pnpm typecheck` reports errors in `src/hooks/useMcpTool.ts` about `execute`, they are pre-existing-arity related and must NOT appear — the descriptor's inline `execute` has fewer params than the type requires, which TypeScript allows. Any other error: fix before proceeding. + +- [ ] **Step 6: Run the full test suite to catch regressions** + +Run: `pnpm test` +Expected: PASS (types-only change). + +- [ ] **Step 7: Commit** + +```bash +git add src/types.ts src/index.ts src/__tests__/type-compat.test.ts +git commit -m "feat: add ToolExecuteCallbackOptions, RegisteredTool, and consumer-API types" +``` + +--- + +### Task 2: Hook — thread the execution signal, abort-as-cancellation + +**Files:** +- Modify: `src/hooks/useMcpTool.ts` +- Test: `src/hooks/__tests__/useMcpTool.test.tsx` (append a new describe block) + +**Interfaces:** +- Consumes: `ToolExecuteCallbackOptions`, `ExecuteToolOptions` from Task 1. +- Produces: `useMcpTool(...).execute(input?, options?)` accepting a caller signal; descriptor `execute(args, options?)` forwarding `options?.signal ?? new AbortController().signal` to `handler(args, { signal })`. Internal helper name: `runHandler` (not exported). + +- [ ] **Step 1: Write the failing tests** + +Append to `src/hooks/__tests__/useMcpTool.test.tsx` (uses the file's existing helpers: `renderWithProvider`, `ToolComponent`, `waitForRegistration`, `OK_RESULT`, `ExecuteFn`; add `ToolDescriptor` and `CallToolResult` to the existing type-only import from `../../types` if not present): + +```tsx +// ─── Execution signal (Chrome 153+ shape) ──────────────────────── + +describe("execution signal", () => { + function installFakeNative() { + const captured: ToolDescriptor[] = []; + const fake = { + registerTool: (tool: ToolDescriptor) => { + captured.push(tool); + return Promise.resolve(undefined); + }, + }; + Object.defineProperty(document, "modelContext", { value: fake, configurable: true }); + return { + captured, + uninstall: () => { + delete (document as { modelContext?: unknown }).modelContext; + }, + }; + } + + it("direct execute() passes a non-aborted AbortSignal to the handler", async () => { + const seen: Array<{ signal: AbortSignal }> = []; + const executeRef = { current: null as ExecuteFn | null }; + renderWithProvider( + { + seen.push(ctx); + return OK_RESULT; + }, + }} + onExecuteRef={executeRef} + />, + ); + await waitForRegistration(); + await act(async () => { + await executeRef.current?.(); + }); + expect(seen).toHaveLength(1); + expect(seen[0].signal).toBeInstanceOf(AbortSignal); + expect(seen[0].signal.aborted).toBe(false); + }); + + it("direct execute() forwards a caller-provided signal", async () => { + let received: AbortSignal | undefined; + const executeRef = { current: null as ExecuteFn | null }; + renderWithProvider( + { + received = signal; + return OK_RESULT; + }, + }} + onExecuteRef={executeRef} + />, + ); + await waitForRegistration(); + const controller = new AbortController(); + await act(async () => { + await executeRef.current?.({}, { signal: controller.signal }); + }); + expect(received).toBe(controller.signal); + }); + + it("aborted execution is cancellation: rethrows but no state.error, no onError", async () => { + const onError = vi.fn(); + const executeRef = { current: null as ExecuteFn | null }; + const { getByTestId } = renderWithProvider( + + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + }} + onExecuteRef={executeRef} + />, + ); + await waitForRegistration(); + const controller = new AbortController(); + let rejected: unknown = null; + let promise!: Promise; + act(() => { + promise = executeRef.current!({}, { signal: controller.signal }); + promise.catch((e: unknown) => { + rejected = e; + }); + }); + await act(async () => { + controller.abort(new DOMException("user cancelled", "AbortError")); + await promise.catch(() => {}); + }); + expect((rejected as { name?: string })?.name).toBe("AbortError"); + expect(onError).not.toHaveBeenCalled(); + expect(getByTestId("error").textContent).toBe("none"); + expect(getByTestId("executing").textContent).toBe("no"); + }); + + it("descriptor execute forwards Chrome's signal and treats abort as cancellation", async () => { + const { captured, uninstall } = installFakeNative(); + try { + const onError = vi.fn(); + const { getByTestId } = renderWithProvider( + + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + }} + />, + ); + await waitFor(() => expect(captured.length).toBeGreaterThan(0)); + const controller = new AbortController(); + let result!: Promise; + act(() => { + result = Promise.resolve(captured[0].execute({}, { signal: controller.signal })); + }); + let settled: CallToolResult | undefined; + await act(async () => { + controller.abort(); + settled = await result; + }); + // Agent path resolves with an isError result rather than rejecting + // (Chrome discards it; rejecting would log a console error in Chrome). + expect(settled?.isError).toBe(true); + expect(onError).not.toHaveBeenCalled(); + expect(getByTestId("error").textContent).toBe("none"); + } finally { + uninstall(); + } + }); + + it("bare descriptor execute (Chrome ≤152 shape) still provides a real signal", async () => { + const { captured, uninstall } = installFakeNative(); + try { + let ctxSeen: { signal: AbortSignal } | undefined; + renderWithProvider( + { + ctxSeen = ctx; + return OK_RESULT; + }, + }} + />, + ); + await waitFor(() => expect(captured.length).toBeGreaterThan(0)); + await act(async () => { + // Chrome ≤152 calls execute with a single argument. + await (captured[0].execute as unknown as (input: Record) => unknown)({}); + }); + expect(ctxSeen?.signal).toBeInstanceOf(AbortSignal); + expect(ctxSeen?.signal.aborted).toBe(false); + } finally { + uninstall(); + } + }); + + it("non-abort failures still set state.error and fire onError", async () => { + const onError = vi.fn(); + const executeRef = { current: null as ExecuteFn | null }; + const { getByTestId } = renderWithProvider( + { + throw new Error("genuine failure"); + }, + }} + onExecuteRef={executeRef} + />, + ); + await waitForRegistration(); + const controller = new AbortController(); // present but never aborted + await act(async () => { + await executeRef.current!({}, { signal: controller.signal }).catch(() => {}); + }); + expect(onError).toHaveBeenCalledTimes(1); + expect(getByTestId("error").textContent).toBe("genuine failure"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test -- src/hooks/__tests__/useMcpTool.test.tsx` +Expected: the new "execution signal" tests FAIL (handler receives `undefined` ctx / cancellation still sets error). All pre-existing tests still PASS. + +- [ ] **Step 3: Implement in `src/hooks/useMcpTool.ts`** + +Add `ExecuteToolOptions` and `ToolExecuteCallbackOptions` to the type-only import from `../types`. + +Replace the body of the `execute` useCallback and the descriptor's inline `execute` with one shared runner. Insert this above the existing `execute` definition (after the mount-tracking `useEffect`): + +```ts + const runHandler = useCallback( + async ( + input: Record, + signal: AbortSignal, + opts: { throwOnError: boolean }, + ): Promise => { + inFlightCountRef.current++; + if (isMountedRef.current) { + setState((prev) => ({ ...prev, isExecuting: true, error: null })); + } + + try { + let validatedInput = input; + const currentConfig = configRef.current; + const currentIsZod = "input" in currentConfig && currentConfig.input instanceof z.ZodObject; + + if (currentIsZod) { + validatedInput = (currentConfig as McpToolConfigZod).input.parse(input); + } + + const result = await handlerRef.current(validatedInput, { signal }); + + inFlightCountRef.current--; + if (isMountedRef.current) { + setState((prev) => ({ + isExecuting: inFlightCountRef.current > 0, + lastResult: result, + error: null, + executionCount: prev.executionCount + 1, + })); + } + + onSuccessRef.current?.(result); + return result; + } catch (thrown) { + const error = thrown instanceof Error ? thrown : new Error(String(thrown)); + inFlightCountRef.current--; + + if (signal.aborted) { + // Cancellation, not error: the agent/user aborted this execution. + // Leave error/lastResult untouched and skip onError. + if (isMountedRef.current) { + setState((prev) => ({ ...prev, isExecuting: inFlightCountRef.current > 0 })); + } + } else { + if (isMountedRef.current) { + setState((prev) => ({ + ...prev, + isExecuting: inFlightCountRef.current > 0, + error, + })); + } + onErrorRef.current?.(error); + } + + if (opts.throwOnError) throw error; + return { + content: [{ type: "text", text: `Error: ${error.message}` }], + isError: true, + }; + } + }, + [], + ); +``` + +Replace the existing `execute` useCallback with: + +```ts + const execute = useCallback( + (input?: Record, options?: ExecuteToolOptions): Promise => + runHandler(input ?? {}, options?.signal ?? new AbortController().signal, { + throwOnError: true, + }), + [runHandler], + ); +``` + +Replace the descriptor's entire inline `execute` property (the ~50-line async function at the bottom of the descriptor literal) with: + +```ts + execute: (args: Record, options?: ToolExecuteCallbackOptions) => + runHandler(args, options?.signal ?? new AbortController().signal, { + throwOnError: false, + }), +``` + +Delete nothing else — registration, ownership tokens, and error routing stay as they are. + +- [ ] **Step 4: Run the hook tests** + +Run: `pnpm test -- src/hooks/__tests__/useMcpTool.test.tsx` +Expected: PASS, including all pre-existing tests (the refactor must not change non-abort behavior: state-update ordering is setState before `onSuccess`/`onError`, in-flight counting is unchanged). + +- [ ] **Step 5: Run the full suite and typecheck** + +Run: `pnpm test && pnpm typecheck` +Expected: PASS (smoke/integration tests exercise the shim path, which still calls `execute(parsed)` one-arg until Task 5 — the substitute signal covers it). + +- [ ] **Step 6: Commit** + +```bash +git add src/hooks/useMcpTool.ts src/hooks/__tests__/useMcpTool.test.tsx +git commit -m "feat: thread execution AbortSignal to handlers; treat abort as cancellation" +``` + +--- + +### Task 3: Polyfill execution engine (`src/polyfill/execute.ts`) + +**Files:** +- Create: `src/polyfill/execute.ts` +- Test: `src/polyfill/__tests__/execute.test.ts` (create) + +**Interfaces:** +- Consumes: `ToolDescriptor` (with Task 1's two-arg `execute`), `validateArgs` from `./validation`. +- Produces: `runTool(tool: ToolDescriptor, inputArguments: string | object, callerSignal?: AbortSignal): Promise` — used by Task 4's `executeTool` and Task 5's shim. + +- [ ] **Step 1: Write the failing tests** + +Create `src/polyfill/__tests__/execute.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { CallToolResult, ToolDescriptor } from "../../types"; +import { runTool } from "../execute"; + +function makeTool(overrides?: Partial): ToolDescriptor { + return { + name: "engine_tool", + description: "engine test tool", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + ...overrides, + }; +} + +const OK: CallToolResult = { content: [{ type: "text", text: "ok" }] }; + +describe("runTool", () => { + it("passes parsed args and a fresh non-aborted signal to execute", async () => { + const execute = vi.fn( + async (_input: Record, options: { signal: AbortSignal }) => { + expect(options.signal).toBeInstanceOf(AbortSignal); + expect(options.signal.aborted).toBe(false); + return OK; + }, + ); + await runTool(makeTool({ execute }), '{"query":"hi"}'); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][0]).toEqual({ query: "hi" }); + }); + + it("accepts an object input without re-parsing", async () => { + const execute = vi.fn(async () => OK); + await runTool(makeTool({ execute }), { query: "hi" }); + expect(execute.mock.calls[0][0]).toEqual({ query: "hi" }); + }); + + it("gives each execution an independent signal", async () => { + const signals: AbortSignal[] = []; + const tool = makeTool({ + inputSchema: undefined, + execute: async (_i, { signal }) => { + signals.push(signal); + return OK; + }, + }); + await runTool(tool, "{}"); + await runTool(tool, "{}"); + expect(signals[0]).not.toBe(signals[1]); + }); + + it("serializes object results to JSON", async () => { + const raw = await runTool(makeTool(), '{"query":"x"}'); + expect(JSON.parse(raw)).toEqual(OK); + }); + + it("stringifies primitive results and maps empty string to 'Operation succeeded'", async () => { + // Non-CallToolResult returns exercise native-parity serialization. + const num = makeTool({ inputSchema: undefined, execute: () => 42 as unknown as CallToolResult }); + expect(await runTool(num, "{}")).toBe("42"); + const empty = makeTool({ inputSchema: undefined, execute: () => "" as unknown as CallToolResult }); + expect(await runTool(empty, "{}")).toBe("Operation succeeded"); + }); + + it("rejects UnknownError for a non-serializable (circular) result", async () => { + const circular: Record = {}; + circular.self = circular; + const tool = makeTool({ + inputSchema: undefined, + execute: () => circular as unknown as CallToolResult, + }); + await expect(runTool(tool, "{}")).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + }); + + it("rejects UnknownError on invalid JSON string input", async () => { + await expect(runTool(makeTool(), "not json")).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + }); + + it("rejects UnknownError on non-object JSON input", async () => { + await expect(runTool(makeTool(), '"a string"')).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + }); + + it("rejects OperationError on schema violation (polyfill-only validation)", async () => { + await expect(runTool(makeTool(), "{}")).rejects.toThrow( + expect.objectContaining({ name: "OperationError", message: 'Missing required field: "query"' }), + ); + }); + + it("rejects with the exact reason for a pre-aborted caller signal", async () => { + const reason = new DOMException("pre-cancelled", "AbortError"); + await expect( + runTool(makeTool(), '{"query":"x"}', AbortSignal.abort(reason)), + ).rejects.toBe(reason); + }); + + it("mid-flight abort: caller gets caller's reason, tool gets a generic AbortError", async () => { + const controller = new AbortController(); + let toolReason: unknown; + const tool = makeTool({ + inputSchema: undefined, + execute: (_i, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + toolReason = signal.reason; + reject(signal.reason); + }, + { once: true }, + ); + }), + }); + const callerReason = new Error("custom cancellation reason"); + const promise = runTool(tool, "{}", controller.signal); + controller.abort(callerReason); + await expect(promise).rejects.toBe(callerReason); + // Chrome 153: the tool-side signal always aborts with a generic AbortError, + // never the caller's custom reason. + expect((toolReason as { name?: string })?.name).toBe("AbortError"); + expect(toolReason).not.toBe(callerReason); + }); + + it("ignores late settlement after abort (no unhandled rejection, result stays rejected)", async () => { + const controller = new AbortController(); + let resolveTool!: (r: CallToolResult) => void; + const tool = makeTool({ + inputSchema: undefined, + execute: () => + new Promise((resolve) => { + resolveTool = resolve; + }), + }); + const promise = runTool(tool, "{}", controller.signal); + controller.abort(); + await expect(promise).rejects.toThrow(expect.objectContaining({ name: "AbortError" })); + resolveTool(OK); // late — must be silently ignored + await new Promise((r) => setTimeout(r, 0)); + }); + + it("swallows late rejection after abort (no unhandled rejection)", async () => { + const controller = new AbortController(); + let rejectTool!: (e: unknown) => void; + const tool = makeTool({ + inputSchema: undefined, + execute: () => + new Promise((_resolve, reject) => { + rejectTool = reject; + }), + }); + const promise = runTool(tool, "{}", controller.signal); + controller.abort(); + await expect(promise).rejects.toThrow(expect.objectContaining({ name: "AbortError" })); + rejectTool(new Error("late failure")); + await new Promise((r) => setTimeout(r, 0)); + }); + + it("rejects UnknownError (message preserved) when the tool fails without abort", async () => { + const tool = makeTool({ + inputSchema: undefined, + execute: () => { + throw new Error("handler broke"); + }, + }); + await expect(runTool(tool, "{}")).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + await expect(runTool(tool, "{}")).rejects.toThrow("handler broke"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test -- src/polyfill/__tests__/execute.test.ts` +Expected: FAIL — `../execute` module does not exist. + +- [ ] **Step 3: Implement `src/polyfill/execute.ts`** + +```ts +import type { ToolDescriptor } from "../types"; +import { validateArgs } from "./validation"; + +function serializeResult(result: unknown): string { + const text = + typeof result === "object" && result !== null + ? JSON.stringify(result) // throws on cycles — handled by the caller + : String(result); + return text === "" ? "Operation succeeded" : text; +} + +/** + * Execute a registered tool the way native Chrome does (152–154 behavior): + * JSON-string or object input, per-execution AbortSignal forwarded to the + * tool, caller abort rejects with the caller signal's reason while the + * tool-side signal aborts with a generic AbortError, late settlement after + * abort is ignored, and results serialize to a string (objects via JSON, + * primitives via String, empty string → "Operation succeeded"). + * + * Deviation from native: input is validated against the tool's inputSchema + * (OperationError) — Chrome does not validate yet (spec issue #92). + */ +export function runTool( + tool: ToolDescriptor, + inputArguments: string | object, + callerSignal?: AbortSignal, +): Promise { + if (callerSignal?.aborted) { + return Promise.reject(callerSignal.reason); + } + + let parsed: unknown; + if (typeof inputArguments === "string") { + try { + parsed = JSON.parse(inputArguments); + } catch { + return Promise.reject(new DOMException("Failed to parse input arguments", "UnknownError")); + } + } else { + parsed = inputArguments; + } + if (typeof parsed !== "object" || parsed === null) { + return Promise.reject( + new DOMException("Input arguments must be a JSON object", "UnknownError"), + ); + } + + if (tool.inputSchema) { + try { + validateArgs(parsed as Record, tool.inputSchema); + } catch (thrown) { + return Promise.reject(thrown); + } + } + + const controller = new AbortController(); + + return new Promise((resolve, reject) => { + let settled = false; + + const onAbort = () => { + if (settled) return; + settled = true; + controller.abort(); // default reason → generic AbortError, matching Chrome + reject(callerSignal?.reason); + }; + callerSignal?.addEventListener("abort", onAbort, { once: true }); + + Promise.resolve() + .then(() => tool.execute(parsed as Record, { signal: controller.signal })) + .then( + (result) => { + if (settled) return; // late settlement after abort — ignored + settled = true; + callerSignal?.removeEventListener("abort", onAbort); + try { + resolve(serializeResult(result)); + } catch { + reject(new DOMException("Tool result is not JSON-serializable", "UnknownError")); + } + }, + (thrown: unknown) => { + if (settled) return; // late rejection after abort — ignored + settled = true; + callerSignal?.removeEventListener("abort", onAbort); + const message = thrown instanceof Error ? thrown.message : String(thrown); + reject(new DOMException(`Tool execution failed: ${message}`, "UnknownError")); + }, + ); + }); +} +``` + +- [ ] **Step 4: Run the engine tests** + +Run: `pnpm test -- src/polyfill/__tests__/execute.test.ts` +Expected: PASS (all 15). + +- [ ] **Step 5: Commit** + +```bash +git add src/polyfill/execute.ts src/polyfill/__tests__/execute.test.ts +git commit -m "feat: add polyfill execution engine with per-execution AbortSignal" +``` + +--- + +### Task 4: Polyfill `getTools()` / `executeTool()` + +**Files:** +- Modify: `src/polyfill/registry.ts` (add `get`) +- Modify: `src/polyfill/index.ts` (PolyfillModelContext gains the consumer API) +- Test: `src/polyfill/__tests__/consumer-api.test.ts` (create) + +**Interfaces:** +- Consumes: `runTool` (Task 3), `isPotentiallyTrustworthyOrigin` from `./validation`, types from Task 1. +- Produces: `RegistryInternal.get(name: string): ToolDescriptor | undefined`; `document.modelContext.getTools(options?)` and `.executeTool(tool, inputArguments, options?)` on the installed polyfill (Task 5's shim and PR 2's adapter rely on these). + +- [ ] **Step 1: Write the failing tests** + +Create `src/polyfill/__tests__/consumer-api.test.ts`: + +```ts +import { afterEach, describe, expect, it } from "vitest"; +import type { CallToolResult, ModelContext, RegisteredTool, ToolDescriptor } from "../../types"; +import { cleanupPolyfill, installPolyfill } from ".."; + +function makeTool(overrides?: Partial): ToolDescriptor { + return { + name: "consumer_tool", + description: "consumer test tool", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + ...overrides, + }; +} + +function mc(): ModelContext { + const m = document.modelContext; + if (!m) throw new Error("polyfill not installed"); + return m; +} + +afterEach(() => { + cleanupPolyfill(); +}); + +describe("document.modelContext.getTools (polyfill)", () => { + it("returns registered tools sorted by name with object inputSchema and defaults", async () => { + installPolyfill(); + await mc().registerTool(makeTool({ name: "b_tool" })); + await mc().registerTool(makeTool({ name: "a_tool" })); + const tools = await mc().getTools!(); + expect(tools.map((t) => t.name)).toEqual(["a_tool", "b_tool"]); + expect(typeof tools[0].inputSchema).toBe("object"); + expect(tools[0].title).toBe(""); + expect(tools[0].description).toBe("consumer test tool"); + expect(tools[0].origin).toBe(location.origin); + expect(tools[0].window).toBe(window); + }); + + it("returns a deep copy of inputSchema (mutation does not leak back)", async () => { + installPolyfill(); + await mc().registerTool(makeTool()); + const [first] = await mc().getTools!(); + (first.inputSchema as Record).type = "mutated"; + const [second] = await mc().getTools!(); + expect((second.inputSchema as Record).type).toBe("object"); + }); + + it("returns fresh objects on every call", async () => { + installPolyfill(); + await mc().registerTool(makeTool()); + const [a] = await mc().getTools!(); + const [b] = await mc().getTools!(); + expect(a).not.toBe(b); + }); + + it("preserves title and annotations when registered", async () => { + installPolyfill(); + await mc().registerTool( + makeTool({ title: "Nice Tool", annotations: { readOnlyHint: true } }), + ); + const [tool] = await mc().getTools!(); + expect(tool.title).toBe("Nice Tool"); + expect(tool.annotations).toEqual({ readOnlyHint: true }); + }); + + it("rejects SecurityError for an untrustworthy fromOrigins entry", async () => { + installPolyfill(); + await expect(mc().getTools!({ fromOrigins: ["http://evil.example"] })).rejects.toThrow( + expect.objectContaining({ name: "SecurityError" }), + ); + }); +}); + +describe("document.modelContext.executeTool (polyfill)", () => { + it("executes by RegisteredTool and resolves the JSON result", async () => { + installPolyfill(); + await mc().registerTool(makeTool()); + const [tool] = await mc().getTools!(); + const raw = await mc().executeTool!(tool, '{"query":"x"}'); + expect(JSON.parse(raw as string)).toEqual({ content: [{ type: "text", text: "ok" }] }); + }); + + it("accepts an object inputArguments", async () => { + installPolyfill(); + await mc().registerTool(makeTool()); + const [tool] = await mc().getTools!(); + const raw = await mc().executeTool!(tool, { query: "x" }); + expect(JSON.parse(raw as string)).toEqual({ content: [{ type: "text", text: "ok" }] }); + }); + + it("rejects UnknownError for a stale/unregistered tool", async () => { + installPolyfill(); + const stale: RegisteredTool = { name: "ghost", description: "gone" }; + await expect(mc().executeTool!(stale, "{}")).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + }); + + it("forwards the caller signal: tool-side signal aborts, caller gets its reason", async () => { + installPolyfill(); + let toolAborted = false; + await mc().registerTool( + makeTool({ + inputSchema: undefined, + execute: (_input, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + toolAborted = true; + reject(signal.reason); + }, + { once: true }, + ); + }), + }), + ); + const [tool] = await mc().getTools!(); + const controller = new AbortController(); + const reason = new Error("cancel it"); + const promise = mc().executeTool!(tool, "{}", { signal: controller.signal }); + controller.abort(reason); + await expect(promise).rejects.toBe(reason); + expect(toolAborted).toBe(true); + }); + + it("keeps an in-flight execution alive when the tool is unregistered mid-flight", async () => { + installPolyfill(); + const registration = new AbortController(); + let resolveTool!: (r: CallToolResult) => void; + let toolSignalAborted = false; + await mc().registerTool( + makeTool({ + inputSchema: undefined, + execute: (_input, { signal }) => { + signal.addEventListener("abort", () => { + toolSignalAborted = true; + }); + return new Promise((resolve) => { + resolveTool = resolve; + }); + }, + }), + { signal: registration.signal }, + ); + const [tool] = await mc().getTools!(); + const pending = mc().executeTool!(tool, "{}"); + await Promise.resolve(); // let execute start + registration.abort(); // unregister mid-flight (Chrome 153.0.8008+ behavior) + resolveTool({ content: [{ type: "text", text: "late-ok" }] }); + const raw = await pending; + expect(JSON.parse(raw as string).content[0].text).toBe("late-ok"); + expect(toolSignalAborted).toBe(false); + expect((await mc().getTools!()).map((t) => t.name)).not.toContain("consumer_tool"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test -- src/polyfill/__tests__/consumer-api.test.ts` +Expected: FAIL — `getTools` is not a function on the polyfill. + +- [ ] **Step 3: Add `get` to the registry** + +In `src/polyfill/registry.ts`, add to the `RegistryInternal` interface: + +```ts + get(name: string): ToolDescriptor | undefined; +``` + +and to the returned object (next to `getTools`): + +```ts + get(name: string): ToolDescriptor | undefined { + return tools.get(name); + }, +``` + +- [ ] **Step 4: Add the consumer API to `PolyfillModelContext` in `src/polyfill/index.ts`** + +Update imports: + +```ts +import type { + ExecuteToolOptions, + InputSchema, + ModelContext, + ModelContextGetToolOptions, + RegisteredTool, +} from "../types"; +import { runTool } from "./execute"; +import { createRegistry, type RegistryInternal } from "./registry"; +import { createTestingShim } from "./testing-shim"; +import { isPotentiallyTrustworthyOrigin } from "./validation"; +``` + +In the class: add a `#registry` field, set it in the constructor, and add the two methods: + +```ts +class PolyfillModelContext extends EventTarget { + readonly __isWebMCPPolyfill = true as const; + registerTool: ModelContext["registerTool"]; + #registry: RegistryInternal; + #ontoolchange: ((ev: Event) => unknown) | null = null; + + constructor(registry: RegistryInternal) { + super(); + this.#registry = registry; + this.registerTool = registry.registerTool; + } + + getTools(options?: ModelContextGetToolOptions): Promise { + if (options?.fromOrigins) { + for (const origin of options.fromOrigins) { + if (!isPotentiallyTrustworthyOrigin(origin)) { + return Promise.reject( + new DOMException( + "Only secure origins are allowed in the fromOrigins list.", + "SecurityError", + ), + ); + } + } + } + // Single-document polyfill: every registered tool is same-origin, so + // fromOrigins never filters anything here. + const tools = Array.from(this.#registry.getTools().values()) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map( + (tool): RegisteredTool => ({ + name: tool.name, + title: tool.title ?? "", + description: tool.description, + ...(tool.inputSchema && { + // JSON round-trip: a deep copy, matching how Chrome 154 materializes + // the object from the schema captured at registration. + inputSchema: JSON.parse(JSON.stringify(tool.inputSchema)) as InputSchema, + }), + ...(tool.annotations && { annotations: { ...tool.annotations } }), + window, + origin: location.origin, + }), + ); + return Promise.resolve(tools); + } + + executeTool( + tool: RegisteredTool, + inputArguments: string | object, + options?: ExecuteToolOptions, + ): Promise { + const registered = tool ? this.#registry.get(tool.name) : undefined; + if (!registered) { + return Promise.reject(new DOMException(`Tool "${tool?.name}" not found`, "UnknownError")); + } + return runTool(registered, inputArguments, options?.signal); + } + + // ontoolchange getter/setter unchanged +``` + +- [ ] **Step 5: Run the tests** + +Run: `pnpm test -- src/polyfill/__tests__/consumer-api.test.ts && pnpm typecheck` +Expected: PASS. Note: the unregister-mid-flight test passes without special code — the engine captured the descriptor before the registry deleted it. + +- [ ] **Step 6: Run the full suite** + +Run: `pnpm test` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/polyfill/registry.ts src/polyfill/index.ts src/polyfill/__tests__/consumer-api.test.ts +git commit -m "feat: add getTools()/executeTool() consumer API to the polyfill" +``` + +--- + +### Task 5: Testing shim → deprecated wrapper over the engine + +**Files:** +- Modify: `src/polyfill/testing-shim.ts` +- Modify: `src/polyfill/__tests__/testing-shim.test.ts` (two assertion updates + one new test) + +**Interfaces:** +- Consumes: `runTool` (Task 3), `RegistryInternal.get` (Task 4), `warnOnce` from `../utils/warn`. +- Produces: unchanged `ModelContextTesting` surface; shim `executeTool` now forwards the caller signal into the tool's `options.signal`. + +- [ ] **Step 1: Update the two behavior-shape tests and add the deprecation test** + +In `src/polyfill/__tests__/testing-shim.test.ts`: + +(a) Replace the test `"calls execute with a single input argument (no client)"` with: + +```ts + it("calls execute with input and an options object carrying an AbortSignal", async () => { + // Chrome 153+ shape: execute(input, { signal }). + const registry = createRegistry(); + const shim = createTestingShim(registry); + let args: unknown[] = []; + await registry.registerTool({ + name: "arity_tool", + description: "checks arity", + execute: (...a: unknown[]) => { + args = a; + return { content: [{ type: "text", text: "ok" }] }; + }, + }); + await shim.executeTool("arity_tool", "{}"); + expect(args).toHaveLength(2); + expect((args[1] as { signal: AbortSignal }).signal).toBeInstanceOf(AbortSignal); + }); +``` + +(b) Replace the test `"does not produce unhandled rejection when handler aborts then throws"` with: + +```ts + it("rejects with the abort reason (not the handler error) when handler aborts then throws", async () => { + // Chrome 152+: once aborted, the caller sees the abort reason; the + // tool's late failure is discarded. No unhandled rejection either way. + const controller = new AbortController(); + const { shim } = setup([ + makeTool({ + execute: () => { + controller.abort(); + throw new Error("handler threw after aborting"); + }, + }), + ]); + + await expect( + shim.executeTool("test_tool", '{"query":"x"}', { signal: controller.signal }), + ).rejects.toThrow(expect.objectContaining({ name: "AbortError" })); + }); +``` + +(c) Add a new test inside the `executeTool` describe: + +```ts + it("warns once about deprecation", async () => { + _resetWarnings(); + const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { shim } = setup(); + await shim.executeTool("test_tool", '{"query":"x"}'); + shim.listTools(); + const deprecationWarns = spy.mock.calls.filter( + (c) => typeof c[0] === "string" && c[0].includes("modelContextTesting is deprecated"), + ); + expect(deprecationWarns).toHaveLength(1); + spy.mockRestore(); + }); +``` + +Add to the imports at the top of the file: `import { _resetWarnings } from "../../utils/warn";` + +- [ ] **Step 2: Run tests to verify the new/changed ones fail** + +Run: `pnpm test -- src/polyfill/__tests__/testing-shim.test.ts` +Expected: the three touched tests FAIL (arity is still 1, abort-then-throw still rejects with the handler error, no deprecation warning). All other shim tests PASS. + +- [ ] **Step 3: Rewrite `src/polyfill/testing-shim.ts`** + +```ts +import type { ModelContextTesting, ModelContextTestingExecuteToolOptions } from "../types"; +import { warnOnce } from "../utils/warn"; +import { runTool } from "./execute"; +import type { RegistryInternal } from "./registry"; + +const DEPRECATION_KEY = "modelContextTesting-deprecated"; +const DEPRECATION_MSG = + "navigator.modelContextTesting is deprecated and will be removed in webmcp-react 0.4.0. " + + "Use document.modelContext.getTools() / executeTool() instead."; + +/** @deprecated Kept for one release as a wrapper over the modelContext consumer API. */ +export function createTestingShim(registry: RegistryInternal): ModelContextTesting { + let offChange: (() => void) | null = null; + return { + listTools() { + warnOnce(DEPRECATION_KEY, DEPRECATION_MSG); + return Array.from(registry.getTools().values()).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema ? JSON.stringify(tool.inputSchema) : undefined, + })); + }, + + async executeTool( + toolName: string, + inputArgsJson: string, + options?: ModelContextTestingExecuteToolOptions, + ): Promise { + warnOnce(DEPRECATION_KEY, DEPRECATION_MSG); + const tool = registry.get(toolName); + if (!tool) { + throw new DOMException(`Tool "${toolName}" not found`, "NotFoundError"); + } + + // This legacy surface keeps its stricter, historical input errors + // (OperationError; arrays rejected) — the engine itself is looser. + let parsed: unknown; + try { + parsed = JSON.parse(inputArgsJson); + } catch { + throw new DOMException("Invalid JSON input", "OperationError"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new DOMException("Input must be a JSON object", "OperationError"); + } + + return runTool(tool, parsed as Record, options?.signal); + }, + + registerToolsChangedCallback(callback: () => void) { + offChange?.(); + offChange = registry.addChangeListener(callback); + }, + + getCrossDocumentScriptToolResult() { + return Promise.resolve("[]"); + }, + }; +} +``` + +- [ ] **Step 4: Run the shim tests** + +Run: `pnpm test -- src/polyfill/__tests__/testing-shim.test.ts` +Expected: PASS. In particular these pass **unchanged**: "propagates handler errors" (the engine's `UnknownError` message contains the original), "rejects with AbortError when signal fires mid-execution", "rejects with AbortError when handler synchronously aborts", "rejects immediately with AbortError for pre-aborted signal" (the default abort reason is itself an `AbortError` DOMException). + +- [ ] **Step 5: Run the full suite** + +Run: `pnpm test && pnpm typecheck` +Expected: PASS — smoke/integration/hook tests run through the shim path and now exercise the two-arg handler call end-to-end. + +- [ ] **Step 6: Commit** + +```bash +git add src/polyfill/testing-shim.ts src/polyfill/__tests__/testing-shim.test.ts +git commit -m "feat: delegate testing shim to the execution engine; deprecate it" +``` + +--- + +### Task 6: Docs, CHANGELOG, version 0.3.0, open PR 1 + +**Files:** +- Modify: `package.json` (version), `CHANGELOG.md`, `README.md`, `docs/api.md`, `AGENTS.md`, `skills/webmcp-add-tool/SKILL.md`, `skills/webmcp-setup/SKILL.md` + +**Interfaces:** +- Consumes: everything shipped in Tasks 1–5. +- Produces: the released 0.3.0 documentation surface; PR 1. + +- [ ] **Step 1: Bump the version** + +In `package.json`: `"version": "0.2.0"` → `"version": "0.3.0"`. + +- [ ] **Step 2: CHANGELOG** + +In `CHANGELOG.md`, retitle the current `## Unreleased` section to `## 0.3.0` and prepend the following to its body (keep the two existing bullets from Unreleased under "Changed"): + +```markdown +Tracks Chrome 152–154 WebMCP changes: execution AbortSignals, the +`document.modelContext` consumer API, and the removal of +`navigator.modelContextTesting` from native Chrome. + +### Added + +- **Handlers receive an execution `AbortSignal`.** Handlers are now called as + `handler(args, { signal })`; on Chrome 153.0.8007.0+ the signal aborts when the agent or + user cancels the call — pass it to `fetch()` and other cancellable work. On Chrome ≤152 + (and for bare `execute(args)` calls) the library substitutes a never-aborting signal, so + the second argument is always safe to use. The hook's `execute(input?, { signal }?)` + accepts a caller signal too. Existing one-argument handlers keep working unchanged. +- **Polyfill consumer API.** `document.modelContext.getTools()` and + `executeTool(tool, inputArguments, { signal }?)`, matching native Chrome: + `RegisteredTool.inputSchema` is a deep-copied object (Chrome 154.0.8014.0+ shape), + `inputArguments` may be a JSON string or an object, execution failures reject with + `UnknownError`, aborts reject with the signal's reason, and unregistering a tool no + longer cancels in-flight executions (Chrome 153.0.8008.0+ behavior). The polyfill + additionally validates input against `inputSchema` (`OperationError`) — native Chrome + does not validate yet. +- New exported types: `ToolExecuteCallbackOptions`, `ExecuteToolOptions`, + `RegisteredTool`, `ModelContextGetToolOptions`. + +### Changed + +- **Abort is cancellation, not error.** When an execution's signal aborts and the handler + rejects, the hook clears `isExecuting` but leaves `state.error` untouched and does not + fire `onError`. +- The testing shim's abort rejections now use the signal's abort reason and its tool + failures reject with `UnknownError` (Chrome 152+ parity); its `OperationError` input + errors and `NotFoundError` are unchanged. + +### Deprecated + +- **`navigator.modelContextTesting`.** Native Chrome removed it in 152.0.7940.0. The + polyfill's shim now delegates to the same engine as `document.modelContext.executeTool()` + and warns once in dev. It will be removed in webmcp-react 0.4.0. +``` + +- [ ] **Step 3: README** + +In `README.md`: +- Update the quick-start `handler` example (the `search_catalog` one near line 43) to show the signal: + +```tsx + handler: async ({ query }, { signal }) => ({ + content: [{ type: "text", text: await searchCatalog(query, { signal }) }], + }), +``` + +- Replace the bullet `**Handlers take a single argument.** ...` in the 0.2.0 breaking-changes section is historical — leave it, but add a new section directly above `## Breaking changes in 0.2.0`: + +```markdown +## What's new in 0.3.0 + +- **Handlers get an execution `AbortSignal`**: `handler(args, { signal })`. Chrome 153+ + aborts it when the agent cancels the call; on older Chrome the library substitutes a + never-aborting signal, so `fetch(url, { signal })` is always safe. One-argument handlers + keep working. +- **Consumer API in the polyfill**: `document.modelContext.getTools()` / + `executeTool(tool, args, { signal }?)` — the same surface native Chrome 152+ ships. +- **`navigator.modelContextTesting` is deprecated** (removed from native Chrome in 152; + removed from this library in 0.4.0). +``` + +- [ ] **Step 4: docs/api.md** + +- Update the `handler` row in the Zod config table to: + `(args, ctx) => CallToolResult \| Promise` — "Tool implementation. Receives the parsed input and `ctx: { signal: AbortSignal }`; the signal aborts when the agent cancels the execution (Chrome 153+; otherwise a never-aborting substitute)." +- Replace the sentence `The `handler` takes a **single argument** ...` with: "The `handler` receives the validated input object and a second `ctx` argument containing the execution `AbortSignal`. Handlers that declare a single parameter keep working." +- Update the `execute(input?)` row to `execute(input?, { signal }?)`. +- Add after the "Return value" section: + +```markdown +### Cancellation + +Each execution gets its own `AbortSignal`, passed to the handler as `ctx.signal`. On +Chrome 153.0.8007.0+ (and via the polyfill's `executeTool`) it aborts when the caller +cancels. When an aborted execution's handler rejects, the hook treats it as +**cancellation**: `isExecuting` clears, but `state.error` stays untouched and `onError` +does not fire. Unregistering a tool (unmount) does **not** cancel in-flight executions +(Chrome 153.0.8008.0+ behavior). +``` + +- In the "Polyfill behavior" section, replace the `navigator.modelContextTesting` bullet with the consumer API and add a compat table: + +```markdown +- `document.modelContext.getTools(options?)` / `executeTool(tool, inputArguments, options?)` + — the consumer API (same shape as native Chrome). `getTools()` resolves sorted, fresh + `RegisteredTool` objects whose `inputSchema` is a deep-copied **object**; + `executeTool` accepts a JSON string or object input, forwards `options.signal` into the + tool's execution signal, rejects `UnknownError` on failure, and — unlike native Chrome — + validates input against `inputSchema` (`OperationError`). +- `navigator.modelContextTesting` — **deprecated** wrapper over the same engine + (`listTools()` keeps returning a JSON-string `inputSchema`); removed in 0.4.0. + +| Chrome | Behavior this library tracks | +| --- | --- | +| ≤152 | `execute(input)` — no tool-side signal (the library substitutes one); `navigator.modelContextTesting` removed in 152.0.7940.0 | +| 153 | `execute(input, { signal })`; unregistration no longer cancels in-flight executions (153.0.8008.0+) | +| 154 | `RegisteredTool.inputSchema` is an object (was a JSON string) | +``` + +- [ ] **Step 5: AGENTS.md** + +- Replace the **Single-arg execute/handler** entry with: + +```markdown +**Two-arg execute/handler**: `descriptor.execute(input, { signal })` and the user +`handler(args, ctx)` receive the execution `AbortSignal` as their second argument (Chrome +153+ shape). There is still no `ModelContextClient`. A missing second argument at runtime +(Chrome ≤152) is substituted with a never-aborting signal. Both execution paths must stay +mirrored — they share `runHandler` in `useMcpTool.ts` and `runTool` in +`polyfill/execute.ts`. +``` + +- In the Project Structure tree, add `│ ├── execute.ts ← shared execution engine (signals, serialization, errors)` under `polyfill/` and change the `testing-shim.ts` line to `← DEPRECATED wrapper over execute.ts (removed in 0.4.0)`. +- In "Architecture Decisions", update the **AbortSignal-only unregistration** entry's last sentence: add "Unregistration does not cancel in-flight executions (Chrome 153.0.8008.0+); they run to completion." + +- [ ] **Step 6: Skills** + +In `skills/webmcp-add-tool/SKILL.md` and `skills/webmcp-setup/SKILL.md`, update one handler example each to the two-arg form with a comment, e.g.: + +```tsx + handler: async ({ query }, { signal }) => { + // signal aborts if the agent cancels — forward it to cancellable work + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); + return { content: [{ type: "text", text: await res.text() }] }; + }, +``` + +- [ ] **Step 7: Full check** + +Run: `pnpm build && pnpm typecheck && pnpm lint && pnpm test` +Expected: all PASS. + +- [ ] **Step 8: Commit and open PR 1** + +```bash +git add -A +git commit -m "docs: 0.3.0 — execution signals, consumer API, modelContextTesting deprecation" +git push -u origin kashish/package-email-support-922aa3 +gh pr create --base main --title "0.3.0: execution AbortSignals + document.modelContext consumer API" --body "$(cat <<'EOF' +Tracks Chrome 152–154 WebMCP changes (spec: docs/superpowers/specs/2026-08-21-webmcp-signal-and-consumer-api-design.md): + +- Handlers now receive the execution AbortSignal: `handler(args, { signal })` (Chrome 153+ shape; never-aborting substitute on ≤152). Abort is treated as cancellation, not error. +- Polyfill gains `document.modelContext.getTools()` / `executeTool()` matching native Chrome (object `inputSchema` per Chrome 154, `UnknownError` failures, signal forwarding, in-flight executions survive unregistration). +- `navigator.modelContextTesting` is deprecated (removed from native Chrome in 152.0.7940.0); the shim now wraps the same engine. Removal planned for 0.4.0. +- Version 0.3.0. + +Extension/examples migration follows in a stacked PR. +EOF +)" +``` + +--- + +## PR 2 — Extension + examples + +> Start from PR 1's head: `git checkout -b kashish/webmcp-extension-modelcontext kashish/package-email-support-922aa3` + +### Task 7: Extension `PageToolApi` adapter (migrate off `modelContextTesting`) + +**Files:** +- Modify: `extension/src/types.ts` +- Modify: `extension/src/content-main.ts` (full rewrite) + +**Interfaces:** +- Consumes: page-side `document.modelContext` (native or webmcp-react 0.3.0 polyfill), `navigator.modelContextTesting` (fallback). +- Produces: `PageToolApi { list(): Promise; execute(toolName, argsJson, signal): Promise; onChange(cb): void }` (internal to content-main); `pendingExecutions: Map` (Task 8 adds the cancel path); page global types `PageModelContext`, `PageRegisteredTool`. + +- [ ] **Step 1: Extend `extension/src/types.ts`** + +Replace the `ModelContextTesting` declaration block at the top with: + +```ts +/** @deprecated Removed from native Chrome in 152; kept as a fallback for pages on webmcp-react ≤0.2.0. */ +interface ModelContextTesting { + listTools(): BrowserTool[]; + executeTool( + toolName: string, + inputArgsJson: string, + options?: { signal?: AbortSignal }, + ): Promise; + registerToolsChangedCallback(callback: () => void): void; +} + +export interface PageRegisteredTool { + name: string; + title?: string; + description: string; + /** JSON string on Chrome ≤153, object on Chrome 154+ / webmcp-react 0.3.0 polyfill. */ + inputSchema?: string | object; + annotations?: Record; + window?: Window; + origin?: string; +} + +export interface PageModelContext extends EventTarget { + getTools?(options?: { fromOrigins?: string[] }): Promise; + executeTool?( + tool: PageRegisteredTool, + inputArguments: string | object, + options?: { signal?: AbortSignal }, + ): Promise; +} + +declare global { + interface Navigator { + modelContextTesting?: ModelContextTesting; + } + interface Document { + modelContext?: PageModelContext; + } +} +``` + +- [ ] **Step 2: Rewrite `extension/src/content-main.ts`** + +Full new content: + +```ts +import type { BrowserTool, PageMessage, PageModelContext, PageRegisteredTool } from "./types"; + +const DEBUG = false; + +console.log("[WebMCP Bridge] content-main loaded"); + +function postToIsolated(message: PageMessage) { + window.postMessage(message, window.location.origin); +} + +function sendToolsUpdate(tools: BrowserTool[]) { + postToIsolated({ type: "WEBMCP_TOOLS_UPDATED", tools }); +} + +function schemaToString(schema: string | object | undefined): string | undefined { + if (schema === undefined || schema === null) return undefined; + return typeof schema === "string" ? schema : JSON.stringify(schema); +} + +interface PageToolApi { + list(): Promise; + execute(toolName: string, argsJson: string, signal: AbortSignal): Promise; + onChange(callback: () => void): void; +} + +// Chrome 150+ native / webmcp-react 0.3.0+ polyfill: the standard consumer API. +function createModelContextApi(mc: PageModelContext): PageToolApi { + const getTools = mc.getTools?.bind(mc); + const executeTool = mc.executeTool?.bind(mc); + if (!getTools || !executeTool) throw new Error("modelContext consumer API unavailable"); + return { + async list() { + const tools = await getTools(); + return tools.map((t: PageRegisteredTool) => ({ + name: t.name, + description: t.description, + inputSchema: schemaToString(t.inputSchema), + })); + }, + async execute(toolName, argsJson, signal) { + 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 }); + } catch (err) { + // Future Chrome may require an object instead of a JSON string. + if (err instanceof TypeError) { + return await executeTool(tool, JSON.parse(argsJson) as object, { signal }); + } + throw err; + } + }, + onChange(callback) { + mc.addEventListener("toolchange", callback); + }, + }; +} + +// Fallback for pages on webmcp-react ≤0.2.0 / Chrome ≤151. +function createTestingApi(ctx: NonNullable): PageToolApi { + return { + list: () => Promise.resolve(ctx.listTools()), + execute: (toolName, argsJson, signal) => ctx.executeTool(toolName, argsJson, { signal }), + onChange: (callback) => ctx.registerToolsChangedCallback(callback), + }; +} + +function detectApi(): PageToolApi | null { + const mc = document.modelContext; + if (mc && typeof mc.getTools === "function" && typeof mc.executeTool === "function") { + return createModelContextApi(mc); + } + const mct = navigator.modelContextTesting; + if (mct) return createTestingApi(mct); + return null; +} + +let api: PageToolApi | null = null; +const pendingExecutions = new Map(); + +function handleMessage(event: MessageEvent) { + if (event.source !== window) return; + const data = event.data; + if (!data || typeof data.type !== "string") return; + + switch (data.type) { + case "WEBMCP_EXECUTE_TOOL": { + if (!api) { + postToIsolated({ + type: "WEBMCP_TOOL_RESULT", + requestId: data.requestId, + result: null, + error: "WebMCP API not available", + }); + break; + } + const controller = new AbortController(); + pendingExecutions.set(data.requestId, controller); + api + .execute(data.toolName, data.argsJson, controller.signal) + .then((result: string | null) => { + postToIsolated({ type: "WEBMCP_TOOL_RESULT", requestId: data.requestId, result }); + }) + .catch((err: unknown) => { + postToIsolated({ + type: "WEBMCP_TOOL_RESULT", + requestId: data.requestId, + result: null, + error: err instanceof Error ? err.message : String(err), + }); + }) + .finally(() => { + pendingExecutions.delete(data.requestId); + }); + break; + } + case "WEBMCP_REQUEST_TOOLS": { + if (api) { + api.list().then(sendToolsUpdate).catch(() => {}); + } + break; + } + } +} + +window.addEventListener("message", handleMessage); + +// Poll for a WebMCP API: native modelContext, the 0.3.0 polyfill, or the +// legacy testing shim (0.2.0 pages). The polyfill installs on provider mount, +// so the API may appear well after document load. +const POLL_INTERVAL = 100; +const POLL_TIMEOUT = 10_000; +let elapsed = 0; + +const pollTimer = setInterval(() => { + elapsed += POLL_INTERVAL; + const found = detectApi(); + + if (found) { + clearInterval(pollTimer); + api = found; + if (DEBUG) console.log("[WebMCP Bridge] WebMCP API found"); + api.list().then(sendToolsUpdate).catch(() => {}); + api.onChange(() => { + api?.list().then(sendToolsUpdate).catch(() => {}); + }); + return; + } + + if (elapsed >= POLL_TIMEOUT) { + clearInterval(pollTimer); + if (DEBUG) console.log("[WebMCP Bridge] no WebMCP API found after 10s, giving up"); + } +}, POLL_INTERVAL); +``` + +- [ ] **Step 3: Typecheck and build the extension** + +Run: `pnpm typecheck:extension && pnpm build:extension` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/types.ts extension/src/content-main.ts +git commit -m "feat(extension): migrate to document.modelContext with modelContextTesting fallback" +``` + +--- + +### Task 8: End-to-end cancellation (`CANCEL_TOOL` through all hops) + +**Files:** +- Modify: `extension/src/types.ts` (three new message types) +- Modify: `extension/src/mcp-server/index.ts` +- Modify: `extension/src/mcp-server/tool-registry.ts` +- Modify: `extension/src/background.ts` +- Modify: `extension/src/content-isolated.ts` +- Modify: `extension/src/content-main.ts` (add the cancel case) + +**Interfaces:** +- Consumes: `pendingExecutions` map from Task 7; MCP SDK `extra.signal` (`RequestHandlerExtra` passed as the second argument to `setRequestHandler` callbacks). +- Produces: `WsCancelToolRequest { type: "CANCEL_TOOL"; requestId }` (server→extension), `RuntimeCancelToolMessage { type: "CANCEL_TOOL"; requestId }` (background→content), `PageCancelToolMessage { type: "WEBMCP_CANCEL_TOOL"; requestId }` (isolated→main); `ToolRegistry.callTool(name, args, signal?)`. + +- [ ] **Step 1: Add the message types in `extension/src/types.ts`** + +```ts +export interface PageCancelToolMessage { + type: "WEBMCP_CANCEL_TOOL"; + requestId: string; +} + +export interface RuntimeCancelToolMessage { + type: "CANCEL_TOOL"; + requestId: string; +} + +export interface WsCancelToolRequest { + type: "CANCEL_TOOL"; + requestId: string; +} +``` + +Add `PageCancelToolMessage` to the `PageMessage` union, `RuntimeCancelToolMessage` to the `RuntimeMessage` union, and `WsCancelToolRequest` to the `WsMessageFromServer` union. + +- [ ] **Step 2: MCP server — forward the SDK's cancellation signal** + +In `extension/src/mcp-server/index.ts`, change the CallTool handler to: + +```ts +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + return registry.callTool( + request.params.name, + (request.params.arguments as Record) ?? {}, + extra.signal, + ); +}); +``` + +- [ ] **Step 3: `ToolRegistry` — abort support** + +In `extension/src/mcp-server/tool-registry.ts`: + +Add `cleanup?: () => void;` to the `PendingCall` interface. Add `WsCancelToolRequest` to the types import. + +Change `callTool` to accept and honor a signal (full replacement of the method from the `requestId` declaration down): + +```ts + async callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { + // ... keep the existing NAMESPACED_RE / tool-exists / ws-connected guards ... + + if (signal?.aborted) { + const err = new Error("Tool call aborted by client"); + err.name = "AbortError"; + throw err; + } + + const requestId = crypto.randomUUID(); + const ws = this.ws; + + return new Promise((resolve, reject) => { + const onAbort = () => { + const pending = this.pendingCalls.get(requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingCalls.delete(requestId); + if (ws.readyState === ws.OPEN) { + ws.send( + JSON.stringify({ type: "CANCEL_TOOL", requestId } satisfies WsCancelToolRequest), + ); + } + const err = new Error("Tool call aborted by client"); + err.name = "AbortError"; + reject(err); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + this.pendingCalls.delete(requestId); + resolve({ + isError: true, + content: [ + { type: "text", text: `Tool call "${name}" timed out after ${CALL_TIMEOUT}ms` }, + ], + }); + }, CALL_TIMEOUT); + + this.pendingCalls.set(requestId, { + resolve, + reject, + timer, + cleanup: () => signal?.removeEventListener("abort", onAbort), + }); + + const message: WsCallToolRequest = { + type: "CALL_TOOL", + requestId, + tabId, + toolName, + argsJson: JSON.stringify(args), + }; + + ws.send(JSON.stringify(message)); + }); + } +``` + +In `handleMessage`'s `TOOL_RESULT` case, call `pending.cleanup?.();` right after `clearTimeout(pending.timer);`. In `clearConnection`, call `pending.cleanup?.();` next to each `clearTimeout(pending.timer);`. + +- [ ] **Step 4: Background — relay `CANCEL_TOOL` to the owning tab** + +In `extension/src/background.ts`, in the WebSocket message switch (next to the existing `case "CALL_TOOL"`), add: + +```ts + case "CANCEL_TOOL": { + const { requestId } = data; + const tabId = pendingCalls.get(requestId); + if (tabId === undefined) break; + // Drop the pending entry so the late error TOOL_RESULT from the page is ignored. + pendingCalls.delete(requestId); + chrome.tabs.sendMessage( + tabId, + { type: "CANCEL_TOOL", requestId } satisfies RuntimeMessage, + () => { + // Touch lastError so a closed tab doesn't log an unchecked-error warning. + void chrome.runtime.lastError; + }, + ); + break; + } +``` + +(The existing `TOOL_RESULT` handler already ignores unknown requestIds via `if (!pendingCalls.has(requestId)) break;` — verify this line exists at `extension/src/background.ts:397` and do not remove it.) + +- [ ] **Step 5: content-isolated — forward to the page** + +In `extension/src/content-isolated.ts`, add to the `chrome.runtime.onMessage` switch: + +```ts + case "CANCEL_TOOL": + window.postMessage( + { + type: "WEBMCP_CANCEL_TOOL", + requestId: message.requestId, + } satisfies PageMessage, + window.location.origin, + ); + sendResponse({ ok: true }); + break; +``` + +- [ ] **Step 6: content-main — abort the pending execution** + +In `extension/src/content-main.ts`, add to the `handleMessage` switch: + +```ts + case "WEBMCP_CANCEL_TOOL": { + pendingExecutions.get(data.requestId)?.abort(); + pendingExecutions.delete(data.requestId); + break; + } +``` + +- [ ] **Step 7: Typecheck and build** + +Run: `pnpm typecheck:extension && pnpm build:extension` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add extension/src +git commit -m "feat(extension): forward MCP client cancellation end-to-end via CANCEL_TOOL" +``` + +--- + +### Task 9: Native-harness probes + +**Files:** +- Modify: `examples/native-harness/src/App.tsx` (the `runSelfTest` function) + +**Interfaces:** +- Consumes: library 0.3.0 types (`document.modelContext.getTools?/executeTool?` are typed optional). +- Produces: self-test lines the PR description cites as verification. + +- [ ] **Step 1: Replace the `navigator.modelContextTesting` section of `runSelfTest`** + +Replace everything from `const t = navigator.modelContextTesting;` through the `add` probe with: + +```tsx + // Consumer API (document.modelContext) — native Chrome 150+ or polyfill 0.3.0+. + if (!mc.getTools || !mc.executeTool) { + log("FAIL: document.modelContext.getTools/executeTool missing (Chrome ≤149 or webmcp-react ≤0.2.0)"); + return; + } + const tools = await mc.getTools(); + const names = tools.map((x) => x.name); + log( + names.includes("echo") && names.includes("add") + ? "PASS: getTools" + : `FAIL: getTools ${names}`, + ); + + // Chrome ≤153 returns inputSchema as a JSON string; 154+/polyfill as an object. + const echoTool = tools.find((x) => x.name === "echo"); + const echoSchema = + typeof echoTool?.inputSchema === "string" + ? JSON.parse(echoTool.inputSchema) + : echoTool?.inputSchema; + log( + echoSchema && typeof echoSchema === "object" && "properties" in echoSchema + ? `PASS: inputSchema normalized (${typeof echoTool?.inputSchema})` + : `FAIL: inputSchema ${JSON.stringify(echoTool?.inputSchema)}`, + ); + + const echoRaw = echoTool ? await mc.executeTool(echoTool, JSON.stringify({ text: "hi" })) : null; + const echo = echoRaw ? JSON.parse(echoRaw) : null; + log( + echo?.content?.[0]?.text?.includes("hi") ? "PASS: executeTool echo" : `FAIL: echo ${echoRaw}`, + ); + + const addTool = tools.find((x) => x.name === "add"); + const addRaw = addTool ? await mc.executeTool(addTool, JSON.stringify({ a: 2, b: 3 })) : null; + const add = addRaw ? JSON.parse(addRaw) : null; + log(add?.content?.[0]?.text?.includes("5") ? "PASS: executeTool add" : `FAIL: add ${addRaw}`); +``` + +- [ ] **Step 2: Add the three new probes** + +Insert after the `add` probe, before the already-aborted-registration probe: + +```tsx + // Probe: handler receives options.signal (Chrome 153.0.8007+ / polyfill 0.3.0+). + { + const reg = new AbortController(); + let sawSignal: unknown = "not-called"; + await mc.registerTool( + { + name: "signal_probe", + description: "Probe execute options.signal.", + execute: (_input: Record, options?: { signal?: AbortSignal }) => { + sawSignal = options?.signal; + return { content: [{ type: "text", text: "ok" }] }; + }, + }, + { signal: reg.signal }, + ); + const probeTool = (await mc.getTools()).find((x) => x.name === "signal_probe"); + if (probeTool) { + await mc.executeTool(probeTool, "{}"); + log( + sawSignal instanceof AbortSignal + ? "PASS: execute received options.signal" + : "INFO: no options.signal (Chrome ≤152)", + ); + } + reg.abort(); + } + + // Probe: caller abort → tool signal aborts (generic AbortError), caller rejects. + { + const reg = new AbortController(); + let toolAborted: string | null = null; + await mc.registerTool( + { + name: "abort_flight_probe", + description: "Probe mid-flight cancellation.", + execute: (_input: Record, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => { + toolAborted = + options.signal?.reason instanceof DOMException + ? options.signal.reason.name + : String(options.signal?.reason); + reject(options.signal?.reason); + }); + }), + }, + { signal: reg.signal }, + ); + const probeTool = (await mc.getTools()).find((x) => x.name === "abort_flight_probe"); + if (probeTool) { + const controller = new AbortController(); + const pending = mc.executeTool(probeTool, "{}", { signal: controller.signal }); + controller.abort(new DOMException("probe cancel", "AbortError")); + await pending.then( + () => log("FAIL: aborted executeTool resolved"), + (err: unknown) => + log( + `PASS: aborted executeTool rejected (caller: ${(err as { name?: string })?.name}, tool: ${toolAborted ?? "no signal (Chrome ≤152)"})`, + ), + ); + } + reg.abort(); + } + + // Probe: unregister mid-flight — execution survives (Chrome 153.0.8008+ / polyfill 0.3.0+). + { + const reg = new AbortController(); + await mc.registerTool( + { + name: "unregister_probe", + description: "Probe unregister-during-execution.", + execute: () => + new Promise((resolve) => + setTimeout(() => resolve({ content: [{ type: "text", text: "survived" }] }), 200), + ), + }, + { signal: reg.signal }, + ); + const probeTool = (await mc.getTools()).find((x) => x.name === "unregister_probe"); + if (probeTool) { + const pending = mc.executeTool(probeTool, "{}"); + reg.abort(); // unregister while in flight + await pending.then( + (raw) => + log( + String(raw).includes("survived") + ? "PASS: unregister does not cancel in-flight execution" + : `FAIL: unexpected result ${raw}`, + ), + (err: unknown) => + log( + `INFO: in-flight execution rejected on unregister (${(err as { name?: string })?.name}; pre-153.0.8008 behavior)`, + ), + ); + } + } +``` + +- [ ] **Step 3: Build the harness** + +Run: `pnpm --filter webmcp-react-native-harness build` +Expected: PASS (tsc + vite). + +- [ ] **Step 4: Commit** + +```bash +git add examples/native-harness/src/App.tsx +git commit -m "feat(examples): native-harness probes for consumer API, signals, and unregister semantics" +``` + +--- + +### Task 10: Playground — DevPanel migration + cancellable `slow_hint` tool + +**Files:** +- Modify: `examples/playground/src/components/DevPanel.tsx` +- Create: `examples/playground/src/tools/SlowHintTool.tsx` +- Modify: the playground component that mounts the tool components (find it with `grep -rn "GameStatusTool" examples/playground/src` — add the new tool beside the existing ones) + +**Interfaces:** +- Consumes: `document.modelContext.getTools/executeTool` (library-typed), `useMcpTool` two-arg handler. +- Produces: a Cancel button in DevPanel; a `slow_hint` tool demonstrating signal-aware handlers. + +- [ ] **Step 1: Create `examples/playground/src/tools/SlowHintTool.tsx`** + +```tsx +import { useMcpTool } from "webmcp-react"; +import { z } from "zod"; + +/** Demonstrates cancellable execution: resolves after 3s unless the execution signal aborts. */ +export function SlowHintTool() { + useMcpTool({ + name: "slow_hint", + description: + "Return a hint after a 3 second delay. Honors cancellation via the execution AbortSignal.", + input: z.object({}), + handler: (_args, { signal }) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve({ content: [{ type: "text", text: "Patience is itself a hint." }] }); + }, 3000); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject( + signal.reason instanceof Error + ? signal.reason + : new DOMException("Aborted", "AbortError"), + ); + }, + { once: true }, + ); + }), + }); + return null; +} +``` + +Mount it: in the file found via `grep -rn "GameStatusTool" examples/playground/src` (the JSX that renders `` etc.), add `import { SlowHintTool } from "./tools/SlowHintTool";` (adjust the relative path to match the neighboring tool imports) and render `` beside the other tool components. + +- [ ] **Step 2: Migrate DevPanel to the consumer API and add Cancel** + +In `examples/playground/src/components/DevPanel.tsx`: + +(a) Add state: `const [abortController, setAbortController] = useState(null);` + +(b) Replace `refreshTools` with an async version that prefers `document.modelContext` (note it must remain safe to call every 2s): + +```tsx + const refreshTools = useCallback(async () => { + const mc = document.modelContext; + let listed: ToolInfo[] = []; + if (mc?.getTools) { + const tools = await mc.getTools(); + listed = tools.map((t) => ({ + name: t.name, + description: t.description, + // Chrome ≤153: string; Chrome 154+/polyfill 0.3.0: object. + inputSchema: + typeof t.inputSchema === "string" + ? t.inputSchema + : JSON.stringify(t.inputSchema ?? { type: "object", properties: {} }), + })); + } else { + const mct = (navigator as any).modelContextTesting; + if (!mct) return; + listed = mct.listTools(); + } + setTools(listed); + setSelectedTool((prev) => { + if (prev && listed.some((t) => t.name === prev)) return prev; + return listed.length > 0 ? listed[0].name : null; + }); + }, []); +``` + +(Remove the now-unused `outputSchema` from `ToolInfo` and its two render usages — the shim never returned it either.) + +(c) Replace the body of `handleExecute`'s `try` block's execution call: + +```tsx + const controller = new AbortController(); + setAbortController(controller); + setIsExecuting(true); + const start = performance.now(); + + try { + JSON.parse(inputValue); // validate + const mc = document.modelContext; + let raw: string | null; + if (mc?.getTools && mc.executeTool) { + const listed = await mc.getTools(); + const tool = listed.find((t) => t.name === selectedTool); + if (!tool) throw new Error(`Tool "${selectedTool}" is no longer registered`); + raw = await mc.executeTool(tool, inputValue, { signal: controller.signal }); + } else { + raw = await (navigator as any).modelContextTesting.executeTool(selectedTool, inputValue, { + signal: controller.signal, + }); + } + // ... keep the existing success setResults block unchanged, using `raw` ... +``` + +and in the `finally` block add `setAbortController(null);` before `setIsExecuting(false);`. + +(d) Replace the Execute button with an Execute/Cancel pair: + +```tsx +
+ + {isExecuting && ( + + )} +
+``` + +- [ ] **Step 3: Build the playground** + +Run: `pnpm --filter webmcp-react-playground build` +Expected: PASS. + +- [ ] **Step 4: Manual verification (documented in the PR)** + +Run: `pnpm dev:playground`, open the app, open DevPanel, select `slow_hint`, Execute, then Cancel within 3s. +Expected: the result row shows an error entry naming the abort (cancellation), and the game tools still execute normally. Record this in the PR description. + +- [ ] **Step 5: Commit** + +```bash +git add examples/playground/src +git commit -m "feat(examples): DevPanel on consumer API with cancellation; add slow_hint tool" +``` + +--- + +### Task 11: Extension docs, CHANGELOG, open PR 2 + +**Files:** +- Modify: `extension/README.md`, `extension/PRIVACY.md`, `CHANGELOG.md` + +**Interfaces:** +- Consumes: Tasks 7–10. +- Produces: PR 2. + +- [ ] **Step 1: Update extension docs** + +In `extension/README.md` and `extension/PRIVACY.md`: replace each mention of `navigator.modelContextTesting` with `document.modelContext` (the consumer API: `getTools()` / `executeTool()`), and where the README describes how the bridge reads tools, note the fallback: "Pages using webmcp-react ≤0.2.0 are still supported via the deprecated `navigator.modelContextTesting` shim." Add one sentence to the README's feature list: "MCP client cancellations are forwarded to the page and abort the tool's execution `AbortSignal`." + +- [ ] **Step 2: CHANGELOG** + +Add at the top of `CHANGELOG.md` (above `## 0.3.0`): + +```markdown +## Unreleased + +### Extension + +- The bridge extension now discovers and executes tools via + `document.modelContext.getTools()` / `executeTool()` (native Chrome 150+ and the + webmcp-react 0.3.0 polyfill), falling back to the deprecated + `navigator.modelContextTesting` for pages on webmcp-react ≤0.2.0. Native Chrome removed + `modelContextTesting` in 152.0.7940.0, which had left the extension unable to see tools + on native Chrome. +- MCP client cancellations (`notifications/cancelled`) are forwarded end-to-end: MCP SDK → + WebSocket `CANCEL_TOOL` → background → content scripts → `executeTool`'s `AbortSignal`, + aborting the tool handler's execution signal. +``` + +- [ ] **Step 3: Full check** + +Run: `pnpm build && pnpm typecheck && pnpm lint && pnpm test && pnpm typecheck:extension && pnpm build:extension && pnpm --filter webmcp-react-playground build && pnpm --filter webmcp-react-native-harness build` +Expected: all PASS. + +- [ ] **Step 4: Commit and open PR 2 (stacked on PR 1)** + +```bash +git add -A +git commit -m "docs(extension): document modelContext migration and cancellation" +git push -u origin kashish/webmcp-extension-modelcontext +gh pr create --base kashish/package-email-support-922aa3 --title "Extension + examples: migrate to document.modelContext, end-to-end cancellation" --body "$(cat <<'EOF' +Stacked on the 0.3.0 library PR (retarget to main after it merges). + +- Bridge extension discovers/executes tools via document.modelContext.getTools()/executeTool() with a modelContextTesting fallback for webmcp-react ≤0.2.0 pages (native Chrome removed modelContextTesting in 152.0.7940.0). +- MCP client cancellation forwarded end-to-end (SDK extra.signal → WS CANCEL_TOOL → background → content scripts → executeTool AbortSignal). +- native-harness: probes for getTools/executeTool, string-vs-object inputSchema, execution signals, mid-flight abort, unregister-survives-execution. +- playground: DevPanel on the consumer API with a Cancel button; new slow_hint tool demonstrates signal-aware handlers. + +Verification: `pnpm typecheck:extension && pnpm build:extension`, example builds, harness probes against the polyfill and Chrome canary, manual DevPanel cancel of slow_hint (see spec §5). +EOF +)" +``` + +--- + +## Plan Self-Review Notes + +- Spec §1 → Task 1; §2 → Task 2; §3 → Tasks 3–5; §4 → Tasks 7–10; §5 tests → Tasks 2–5 test steps, docs/versioning → Tasks 6 and 11; risks (object-only `executeTool` input) → Task 7's `TypeError` retry and Task 3's object-input support. +- The shim keeps its historical `OperationError`/`NotFoundError` input errors by parsing before delegating (Task 5), so only two existing tests change shape — both updated with Chrome-version comments, none deleted. +- Names used across tasks are consistent: `ToolExecuteCallbackOptions`, `ExecuteToolOptions`, `RegisteredTool`, `runHandler`, `runTool`, `RegistryInternal.get`, `PageToolApi`, `pendingExecutions`, `PendingCall.cleanup`, `CANCEL_TOOL`/`WEBMCP_CANCEL_TOOL`. From 324c4d251027f2440fe5c2ed09855aa39cea8eb8 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 20:03:44 -0400 Subject: [PATCH 03/12] docs(plan): Task 1 must bridge one-arg call sites when handler types become two-arg --- .../plans/2026-08-21-webmcp-signal-and-consumer-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-21-webmcp-signal-and-consumer-api.md b/docs/superpowers/plans/2026-08-21-webmcp-signal-and-consumer-api.md index b3d9519..edb7bf3 100644 --- a/docs/superpowers/plans/2026-08-21-webmcp-signal-and-consumer-api.md +++ b/docs/superpowers/plans/2026-08-21-webmcp-signal-and-consumer-api.md @@ -229,7 +229,7 @@ Add to the existing `export type` block: `ExecuteToolOptions`, `ModelContextGetT - [ ] **Step 5: Run typecheck and tests** Run: `pnpm typecheck && pnpm test -- src/__tests__/type-compat.test.ts` -Expected: typecheck PASS (the hook still compiles because one-arg implementations are assignable to two-arg types); type-compat test PASS. If `pnpm typecheck` reports errors in `src/hooks/useMcpTool.ts` about `execute`, they are pre-existing-arity related and must NOT appear — the descriptor's inline `execute` has fewer params than the type requires, which TypeScript allows. Any other error: fix before proceeding. +Expected: type-compat test PASS. `pnpm typecheck` will report two kinds of errors that you must bridge minimally (one-arg *implementations* are assignable to two-arg types, but one-arg *calls* of a two-arg function type are not): (a) `src/hooks/useMcpTool.ts` calls `handlerRef.current(validatedInput)` with one argument in two places — change both calls to `handlerRef.current(validatedInput, { signal: new AbortController().signal })` (Task 2 replaces this with the real signal); (b) `src/polyfill/testing-shim.ts` calls `tool.execute(parsed)` with one argument — cast to a one-arg legacy type at that call site (`tool.execute as (input: Record) => MaybePromise`) so the existing arity test keeps passing (Task 5 rewrites the shim). No other behavior changes in those files. Any other error: fix before proceeding. - [ ] **Step 6: Run the full test suite to catch regressions** From 23231acefddca6dc52b4c468b3b7e31bb200b784 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 20:05:11 -0400 Subject: [PATCH 04/12] feat: add ToolExecuteCallbackOptions, RegisteredTool, and consumer-API types --- src/__tests__/type-compat.test.ts | 76 +++++++++++++++++++++++++++++++ src/hooks/useMcpTool.ts | 8 +++- src/index.ts | 4 ++ src/polyfill/testing-shim.ts | 14 +++++- src/types.ts | 53 +++++++++++++++++++-- 5 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/type-compat.test.ts diff --git a/src/__tests__/type-compat.test.ts b/src/__tests__/type-compat.test.ts new file mode 100644 index 0000000..ac4c433 --- /dev/null +++ b/src/__tests__/type-compat.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { + ExecuteToolOptions, + McpToolConfigJsonSchema, + McpToolConfigZod, + RegisteredTool, + ToolExecuteCallbackOptions, +} from "../types"; + +// Compile-time assertions. One-argument handlers must remain assignable after +// the handler signature gains a second (ctx) parameter, and the new consumer +// API types must accept both the Chrome ≤153 (string) and 154+ (object) +// inputSchema shapes. + +const oneArgJson: McpToolConfigJsonSchema = { + name: "one_arg", + description: "legacy single-arg handler", + handler: async (args) => ({ + content: [{ type: "text", text: String(Object.keys(args).length) }], + }), +}; + +const twoArgJson: McpToolConfigJsonSchema = { + name: "two_arg", + description: "signal-aware handler", + handler: async (_args, ctx: ToolExecuteCallbackOptions) => { + ctx.signal.throwIfAborted(); + return { content: [{ type: "text", text: "ok" }] }; + }, +}; + +const oneArgZod: McpToolConfigZod<{ q: z.ZodString }> = { + name: "zod_one", + description: "legacy single-arg zod handler", + input: z.object({ q: z.string() }), + handler: async ({ q }) => ({ content: [{ type: "text", text: q }] }), +}; + +const twoArgZod: McpToolConfigZod<{ q: z.ZodString }> = { + name: "zod_two", + description: "signal-aware zod handler", + input: z.object({ q: z.string() }), + handler: async ({ q }, { signal }) => { + signal.throwIfAborted(); + return { content: [{ type: "text", text: q }] }; + }, +}; + +const objectSchema: RegisteredTool = { + name: "t1", + description: "d", + inputSchema: { type: "object", properties: {} }, +}; + +const stringSchema: RegisteredTool = { + name: "t2", + description: "d", + inputSchema: '{"type":"object"}', +}; + +const opts: ExecuteToolOptions = { signal: new AbortController().signal }; + +describe("type compatibility", () => { + it("compiles", () => { + expect([ + oneArgJson, + twoArgJson, + oneArgZod, + twoArgZod, + objectSchema, + stringSchema, + opts, + ]).toHaveLength(7); + }); +}); diff --git a/src/hooks/useMcpTool.ts b/src/hooks/useMcpTool.ts index db777cb..28436d0 100644 --- a/src/hooks/useMcpTool.ts +++ b/src/hooks/useMcpTool.ts @@ -113,7 +113,9 @@ export function useMcpTool( ); } - const result = await handlerRef.current(validatedInput as Record); + const result = await handlerRef.current(validatedInput as Record, { + signal: new AbortController().signal, + }); if (isMountedRef.current) { inFlightCountRef.current--; @@ -198,7 +200,9 @@ export function useMcpTool( validatedArgs = (currentConfig as McpToolConfigZod).input.parse(args); } - const result = await handlerRef.current(validatedArgs as Record); + const result = await handlerRef.current(validatedArgs as Record, { + signal: new AbortController().signal, + }); if (isMountedRef.current) { inFlightCountRef.current--; diff --git a/src/index.ts b/src/index.ts index 7bfbd8a..af4aa33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,14 @@ export { useWebMCPStatus, WebMCPProvider } from "./context"; export { useMcpTool } from "./hooks/useMcpTool"; export type { CallToolResult, + ExecuteToolOptions, McpToolConfigJsonSchema, McpToolConfigZod, + ModelContextGetToolOptions, + RegisteredTool, RegisterToolOptions, ToolAnnotations, + ToolExecuteCallbackOptions, ToolExecutionState, UseMcpToolReturn, WebMCPProviderProps, diff --git a/src/polyfill/testing-shim.ts b/src/polyfill/testing-shim.ts index c06e5e9..eb6b1f5 100644 --- a/src/polyfill/testing-shim.ts +++ b/src/polyfill/testing-shim.ts @@ -1,4 +1,9 @@ -import type { ModelContextTesting, ModelContextTestingExecuteToolOptions } from "../types"; +import type { + CallToolResult, + MaybePromise, + ModelContextTesting, + ModelContextTestingExecuteToolOptions, +} from "../types"; import type { RegistryInternal } from "./registry"; import { validateArgs } from "./validation"; @@ -60,7 +65,12 @@ export function createTestingShim(registry: RegistryInternal): ModelContextTesti } try { - const resultPromise = Promise.resolve(tool.execute(parsed as Record)); + // This deprecated shim predates the two-argument execute(input, options) + // signature and intentionally keeps calling with a single argument. + const legacyExecute = tool.execute as ( + input: Record, + ) => MaybePromise; + const resultPromise = Promise.resolve(legacyExecute(parsed as Record)); const result = abortPromise ? await Promise.race([abortPromise, resultPromise]) diff --git a/src/types.ts b/src/types.ts index c92ee06..2f61925 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,16 @@ import type { z } from "zod"; export type MaybePromise = T | Promise; +/** Second argument to a tool's execute callback / useMcpTool handler (Chrome 153+ shape). */ +export interface ToolExecuteCallbackOptions { + signal: AbortSignal; +} + +/** Options for ModelContext.executeTool and UseMcpToolReturn.execute. */ +export interface ExecuteToolOptions { + signal?: AbortSignal; +} + export interface InputSchemaProperty { type: string; description?: string; @@ -74,7 +84,7 @@ export interface ToolDescriptor> { inputSchema?: InputSchema; outputSchema?: InputSchema; annotations?: ToolAnnotations; - execute: (input: TArgs) => MaybePromise; + execute: (input: TArgs, options: ToolExecuteCallbackOptions) => MaybePromise; } interface McpToolConfigBase { @@ -92,7 +102,10 @@ export interface McpToolConfigZod extends McpToolConfig inputSchema?: never; output?: z.ZodObject; outputSchema?: never; - handler: (args: z.infer>) => MaybePromise; + handler: ( + args: z.infer>, + ctx: ToolExecuteCallbackOptions, + ) => MaybePromise; } export interface McpToolConfigJsonSchema extends McpToolConfigBase { @@ -100,7 +113,10 @@ export interface McpToolConfigJsonSchema extends McpToolConfigBase { inputSchema?: InputSchema; output?: never; outputSchema?: InputSchema; - handler: (args: Record) => MaybePromise; + handler: ( + args: Record, + ctx: ToolExecuteCallbackOptions, + ) => MaybePromise; } export interface ToolExecutionState { @@ -112,7 +128,7 @@ export interface ToolExecutionState { export interface UseMcpToolReturn { state: ToolExecutionState; - execute: (input?: Record) => Promise; + execute: (input?: Record, options?: ExecuteToolOptions) => Promise; reset: () => void; } @@ -131,8 +147,34 @@ export interface RegisterToolOptions { exposedTo?: string[]; } +/** + * Tool metadata returned by ModelContext.getTools(). + * `inputSchema` is an object on Chrome 154+ and this library's polyfill, but a + * JSON string on Chrome ≤153 — consumers must handle both: + * `typeof s === "string" ? JSON.parse(s) : s`. + */ +export interface RegisteredTool { + name: string; + title?: string; + description: string; + inputSchema?: InputSchema | string; + annotations?: ToolAnnotations; + window?: Window; + origin?: string; +} + +export interface ModelContextGetToolOptions { + fromOrigins?: string[]; +} + export interface ModelContext extends EventTarget { registerTool(tool: ToolDescriptor, options?: RegisterToolOptions): Promise; + getTools?(options?: ModelContextGetToolOptions): Promise; + executeTool?( + tool: RegisteredTool, + inputArguments: string | object, + options?: ExecuteToolOptions, + ): Promise; ontoolchange: ((this: ModelContext, ev: Event) => unknown) | null; addEventListener( type: "toolchange", @@ -146,16 +188,19 @@ export interface ModelContext extends EventTarget { ): void; } +/** @deprecated Removed from native Chrome in 152; use ModelContext.getTools()/executeTool(). Will be removed in webmcp-react 0.4.0. */ export interface ModelContextTestingToolInfo { name: string; description: string; inputSchema?: string; } +/** @deprecated Removed from native Chrome in 152. Will be removed in webmcp-react 0.4.0. */ export interface ModelContextTestingExecuteToolOptions { signal?: AbortSignal; } +/** @deprecated Removed from native Chrome in 152; use document.modelContext.getTools()/executeTool(). Will be removed in webmcp-react 0.4.0. */ export interface ModelContextTesting { listTools(): ModelContextTestingToolInfo[]; executeTool( From 7d21813273eb5f8ddd9843fcd58ecd23be2ca46d Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 20:14:44 -0400 Subject: [PATCH 05/12] feat: thread execution AbortSignal to handlers; treat abort as cancellation --- src/hooks/__tests__/useMcpTool.test.tsx | 205 +++++++++++++++++++++++- src/hooks/useMcpTool.ts | 167 ++++++++----------- 2 files changed, 273 insertions(+), 99 deletions(-) diff --git a/src/hooks/__tests__/useMcpTool.test.tsx b/src/hooks/__tests__/useMcpTool.test.tsx index f93a092..9379625 100644 --- a/src/hooks/__tests__/useMcpTool.test.tsx +++ b/src/hooks/__tests__/useMcpTool.test.tsx @@ -5,7 +5,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { _resetPolyfillConsumerCount, WebMCPProvider } from "../../context"; import { cleanupPolyfill } from "../../polyfill"; -import type { CallToolResult, McpToolConfigJsonSchema, McpToolConfigZod } from "../../types"; +import type { + CallToolResult, + McpToolConfigJsonSchema, + McpToolConfigZod, + ToolDescriptor, +} from "../../types"; import { _resetWarnings } from "../../utils/warn"; import { _resetToolOwners, useMcpTool } from "../useMcpTool"; @@ -1496,3 +1501,201 @@ describe("native registerTool compatibility (void return / sync throw)", () => { expect(last?.error).toBeNull(); }); }); + +// ─── Execution signal (Chrome 153+ shape) ──────────────────────── + +describe("execution signal", () => { + function installFakeNative() { + const captured: ToolDescriptor[] = []; + const fake = { + registerTool: (tool: ToolDescriptor) => { + captured.push(tool); + return Promise.resolve(undefined); + }, + }; + Object.defineProperty(document, "modelContext", { value: fake, configurable: true }); + return { + captured, + uninstall: () => { + delete (document as { modelContext?: unknown }).modelContext; + }, + }; + } + + it("direct execute() passes a non-aborted AbortSignal to the handler", async () => { + const seen: Array<{ signal: AbortSignal }> = []; + const executeRef = { current: null as ExecuteFn | null }; + renderWithProvider( + { + seen.push(ctx); + return OK_RESULT; + }, + }} + onExecuteRef={executeRef} + />, + ); + await waitForRegistration(); + await act(async () => { + await executeRef.current?.(); + }); + expect(seen).toHaveLength(1); + expect(seen[0].signal).toBeInstanceOf(AbortSignal); + expect(seen[0].signal.aborted).toBe(false); + }); + + it("direct execute() forwards a caller-provided signal", async () => { + let received: AbortSignal | undefined; + const executeRef = { current: null as ExecuteFn | null }; + renderWithProvider( + { + received = signal; + return OK_RESULT; + }, + }} + onExecuteRef={executeRef} + />, + ); + await waitForRegistration(); + const controller = new AbortController(); + await act(async () => { + await executeRef.current?.({}, { signal: controller.signal }); + }); + expect(received).toBe(controller.signal); + }); + + it("aborted execution is cancellation: rethrows but no state.error, no onError", async () => { + const onError = vi.fn(); + const executeRef = { current: null as ExecuteFn | null }; + const { getByTestId } = renderWithProvider( + + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + }} + onExecuteRef={executeRef} + />, + ); + await waitForRegistration(); + const controller = new AbortController(); + let rejected: unknown = null; + let promise!: Promise; + act(() => { + promise = executeRef.current!({}, { signal: controller.signal }); + promise.catch((e: unknown) => { + rejected = e; + }); + }); + await act(async () => { + controller.abort(new DOMException("user cancelled", "AbortError")); + await promise.catch(() => {}); + }); + expect((rejected as { name?: string })?.name).toBe("AbortError"); + expect(onError).not.toHaveBeenCalled(); + expect(getByTestId("error").textContent).toBe("none"); + expect(getByTestId("executing").textContent).toBe("no"); + }); + + it("descriptor execute forwards Chrome's signal and treats abort as cancellation", async () => { + const { captured, uninstall } = installFakeNative(); + try { + const onError = vi.fn(); + const { getByTestId } = renderWithProvider( + + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + }} + />, + ); + await waitFor(() => expect(captured.length).toBeGreaterThan(0)); + const controller = new AbortController(); + let result!: Promise; + act(() => { + result = Promise.resolve(captured[0].execute({}, { signal: controller.signal })); + }); + let settled: CallToolResult | undefined; + await act(async () => { + controller.abort(); + settled = await result; + }); + // Agent path resolves with an isError result rather than rejecting + // (Chrome discards it; rejecting would log a console error in Chrome). + expect(settled?.isError).toBe(true); + expect(onError).not.toHaveBeenCalled(); + expect(getByTestId("error").textContent).toBe("none"); + } finally { + uninstall(); + } + }); + + it("bare descriptor execute (Chrome ≤152 shape) still provides a real signal", async () => { + const { captured, uninstall } = installFakeNative(); + try { + let ctxSeen: { signal: AbortSignal } | undefined; + renderWithProvider( + { + ctxSeen = ctx; + return OK_RESULT; + }, + }} + />, + ); + await waitFor(() => expect(captured.length).toBeGreaterThan(0)); + await act(async () => { + // Chrome ≤152 calls execute with a single argument. + await (captured[0].execute as unknown as (input: Record) => unknown)({}); + }); + expect(ctxSeen?.signal).toBeInstanceOf(AbortSignal); + expect(ctxSeen?.signal.aborted).toBe(false); + } finally { + uninstall(); + } + }); + + it("non-abort failures still set state.error and fire onError", async () => { + const onError = vi.fn(); + const executeRef = { current: null as ExecuteFn | null }; + const { getByTestId } = renderWithProvider( + { + throw new Error("genuine failure"); + }, + }} + onExecuteRef={executeRef} + />, + ); + await waitForRegistration(); + const controller = new AbortController(); // present but never aborted + await act(async () => { + await executeRef.current!({}, { signal: controller.signal }).catch(() => {}); + }); + expect(onError).toHaveBeenCalledTimes(1); + expect(getByTestId("error").textContent).toBe("genuine failure"); + }); +}); diff --git a/src/hooks/useMcpTool.ts b/src/hooks/useMcpTool.ts index 28436d0..57e938c 100644 --- a/src/hooks/useMcpTool.ts +++ b/src/hooks/useMcpTool.ts @@ -3,9 +3,11 @@ import { z } from "zod"; import { MISSING_PROVIDER, WebMCPContext } from "../context"; import type { CallToolResult, + ExecuteToolOptions, McpToolConfigJsonSchema, McpToolConfigZod, ToolDescriptor, + ToolExecuteCallbackOptions, ToolExecutionState, UseMcpToolReturn, } from "../types"; @@ -98,57 +100,78 @@ export function useMcpTool( }; }, []); - const execute = useCallback(async (input?: Record): Promise => { - inFlightCountRef.current++; - setState((prev) => ({ ...prev, isExecuting: true, error: null })); - - try { - let validatedInput: Record = input ?? {}; - const currentConfig = configRef.current; - const currentIsZod = "input" in currentConfig && currentConfig.input instanceof z.ZodObject; - - if (currentIsZod) { - validatedInput = (currentConfig as McpToolConfigZod).input.parse( - validatedInput, - ); + const runHandler = useCallback( + async ( + input: Record, + signal: AbortSignal, + opts: { throwOnError: boolean }, + ): Promise => { + inFlightCountRef.current++; + if (isMountedRef.current) { + setState((prev) => ({ ...prev, isExecuting: true, error: null })); } - const result = await handlerRef.current(validatedInput as Record, { - signal: new AbortController().signal, - }); + try { + let validatedInput = input; + const currentConfig = configRef.current; + const currentIsZod = "input" in currentConfig && currentConfig.input instanceof z.ZodObject; - if (isMountedRef.current) { - inFlightCountRef.current--; - setState((prev) => ({ - isExecuting: inFlightCountRef.current > 0, - lastResult: result, - error: null, - executionCount: prev.executionCount + 1, - })); - } else { - inFlightCountRef.current--; - } + if (currentIsZod) { + validatedInput = (currentConfig as McpToolConfigZod).input.parse(input); + } - onSuccessRef.current?.(result); - return result; - } catch (thrown) { - const error = thrown instanceof Error ? thrown : new Error(String(thrown)); + const result = await handlerRef.current(validatedInput, { signal }); - if (isMountedRef.current) { inFlightCountRef.current--; - setState((prev) => ({ - ...prev, - isExecuting: inFlightCountRef.current > 0, - error, - })); - } else { + if (isMountedRef.current) { + setState((prev) => ({ + isExecuting: inFlightCountRef.current > 0, + lastResult: result, + error: null, + executionCount: prev.executionCount + 1, + })); + } + + onSuccessRef.current?.(result); + return result; + } catch (thrown) { + const error = normalizeError(thrown); inFlightCountRef.current--; + + if (signal.aborted) { + // Cancellation, not error: the agent/user aborted this execution. + // Leave error/lastResult untouched and skip onError. + if (isMountedRef.current) { + setState((prev) => ({ ...prev, isExecuting: inFlightCountRef.current > 0 })); + } + } else { + if (isMountedRef.current) { + setState((prev) => ({ + ...prev, + isExecuting: inFlightCountRef.current > 0, + error, + })); + } + onErrorRef.current?.(error); + } + + if (opts.throwOnError) throw error; + return { + content: [{ type: "text", text: `Error: ${error.message}` }], + isError: true, + }; } + }, + [], + ); - onErrorRef.current?.(error); - throw error; - } - }, []); + const execute = useCallback( + (input?: Record, options?: ExecuteToolOptions): Promise => + runHandler(input ?? {}, options?.signal ?? new AbortController().signal, { + throwOnError: true, + }), + [runHandler], + ); const reset = useCallback(() => { setState(INITIAL_STATE); @@ -184,62 +207,10 @@ export function useMcpTool( ...(resolvedInputSchema && { inputSchema: resolvedInputSchema }), ...(resolvedOutputSchema && { outputSchema: resolvedOutputSchema }), ...(cfg.annotations && { annotations: cfg.annotations }), - execute: async (args: Record): Promise => { - inFlightCountRef.current++; - if (isMountedRef.current) { - setState((prev) => ({ ...prev, isExecuting: true, error: null })); - } - - try { - let validatedArgs = args; - const currentConfig = configRef.current; - const currentIsZod = - "input" in currentConfig && currentConfig.input instanceof z.ZodObject; - - if (currentIsZod) { - validatedArgs = (currentConfig as McpToolConfigZod).input.parse(args); - } - - const result = await handlerRef.current(validatedArgs as Record, { - signal: new AbortController().signal, - }); - - if (isMountedRef.current) { - inFlightCountRef.current--; - setState((prev) => ({ - isExecuting: inFlightCountRef.current > 0, - lastResult: result, - error: null, - executionCount: prev.executionCount + 1, - })); - } else { - inFlightCountRef.current--; - } - - onSuccessRef.current?.(result); - return result; - } catch (thrown) { - const error = thrown instanceof Error ? thrown : new Error(String(thrown)); - - if (isMountedRef.current) { - inFlightCountRef.current--; - setState((prev) => ({ - ...prev, - isExecuting: inFlightCountRef.current > 0, - error, - })); - } else { - inFlightCountRef.current--; - } - - onErrorRef.current?.(error); - - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - isError: true, - }; - } - }, + execute: (args: Record, options?: ToolExecuteCallbackOptions) => + runHandler(args, options?.signal ?? new AbortController().signal, { + throwOnError: false, + }), }; const controller = new AbortController(); From a055a6872510e10e12176b3ddf9088cc608f927c Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 20:25:44 -0400 Subject: [PATCH 06/12] feat: add polyfill execution engine with per-execution AbortSignal --- src/polyfill/__tests__/execute.test.ts | 187 +++++++++++++++++++++++++ src/polyfill/execute.ts | 100 +++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 src/polyfill/__tests__/execute.test.ts create mode 100644 src/polyfill/execute.ts diff --git a/src/polyfill/__tests__/execute.test.ts b/src/polyfill/__tests__/execute.test.ts new file mode 100644 index 0000000..5a9f8a1 --- /dev/null +++ b/src/polyfill/__tests__/execute.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CallToolResult, ToolDescriptor } from "../../types"; +import { runTool } from "../execute"; + +function makeTool(overrides?: Partial): ToolDescriptor { + return { + name: "engine_tool", + description: "engine test tool", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + ...overrides, + }; +} + +const OK: CallToolResult = { content: [{ type: "text", text: "ok" }] }; + +describe("runTool", () => { + it("passes parsed args and a fresh non-aborted signal to execute", async () => { + const execute = vi.fn( + async (_input: Record, options: { signal: AbortSignal }) => { + expect(options.signal).toBeInstanceOf(AbortSignal); + expect(options.signal.aborted).toBe(false); + return OK; + }, + ); + await runTool(makeTool({ execute }), '{"query":"hi"}'); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][0]).toEqual({ query: "hi" }); + }); + + it("accepts an object input without re-parsing", async () => { + const execute = vi.fn(async () => OK); + await runTool(makeTool({ execute }), { query: "hi" }); + expect(execute.mock.calls[0][0]).toEqual({ query: "hi" }); + }); + + it("gives each execution an independent signal", async () => { + const signals: AbortSignal[] = []; + const tool = makeTool({ + inputSchema: undefined, + execute: async (_i, { signal }) => { + signals.push(signal); + return OK; + }, + }); + await runTool(tool, "{}"); + await runTool(tool, "{}"); + expect(signals[0]).not.toBe(signals[1]); + }); + + it("serializes object results to JSON", async () => { + const raw = await runTool(makeTool(), '{"query":"x"}'); + expect(JSON.parse(raw)).toEqual(OK); + }); + + it("stringifies primitive results and maps empty string to 'Operation succeeded'", async () => { + // Non-CallToolResult returns exercise native-parity serialization. + const num = makeTool({ + inputSchema: undefined, + execute: () => 42 as unknown as CallToolResult, + }); + expect(await runTool(num, "{}")).toBe("42"); + const empty = makeTool({ + inputSchema: undefined, + execute: () => "" as unknown as CallToolResult, + }); + expect(await runTool(empty, "{}")).toBe("Operation succeeded"); + }); + + it("rejects UnknownError for a non-serializable (circular) result", async () => { + const circular: Record = {}; + circular.self = circular; + const tool = makeTool({ + inputSchema: undefined, + execute: () => circular as unknown as CallToolResult, + }); + await expect(runTool(tool, "{}")).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + }); + + it("rejects UnknownError on invalid JSON string input", async () => { + await expect(runTool(makeTool(), "not json")).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + }); + + it("rejects UnknownError on non-object JSON input", async () => { + await expect(runTool(makeTool(), '"a string"')).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + }); + + it("rejects OperationError on schema violation (polyfill-only validation)", async () => { + await expect(runTool(makeTool(), "{}")).rejects.toThrow( + expect.objectContaining({ + name: "OperationError", + message: 'Missing required field: "query"', + }), + ); + }); + + it("rejects with the exact reason for a pre-aborted caller signal", async () => { + const reason = new DOMException("pre-cancelled", "AbortError"); + await expect(runTool(makeTool(), '{"query":"x"}', AbortSignal.abort(reason))).rejects.toBe( + reason, + ); + }); + + it("mid-flight abort: caller gets caller's reason, tool gets a generic AbortError", async () => { + const controller = new AbortController(); + let toolReason: unknown; + const tool = makeTool({ + inputSchema: undefined, + execute: (_i, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + toolReason = signal.reason; + reject(signal.reason); + }, + { once: true }, + ); + }), + }); + const callerReason = new Error("custom cancellation reason"); + const promise = runTool(tool, "{}", controller.signal); + controller.abort(callerReason); + await expect(promise).rejects.toBe(callerReason); + // Chrome 153: the tool-side signal always aborts with a generic AbortError, + // never the caller's custom reason. + expect((toolReason as { name?: string })?.name).toBe("AbortError"); + expect(toolReason).not.toBe(callerReason); + }); + + it("ignores late settlement after abort (no unhandled rejection, result stays rejected)", async () => { + const controller = new AbortController(); + let resolveTool!: (r: CallToolResult) => void; + const tool = makeTool({ + inputSchema: undefined, + execute: () => + new Promise((resolve) => { + resolveTool = resolve; + }), + }); + const promise = runTool(tool, "{}", controller.signal); + controller.abort(); + await expect(promise).rejects.toThrow(expect.objectContaining({ name: "AbortError" })); + resolveTool(OK); // late — must be silently ignored + await new Promise((r) => setTimeout(r, 0)); + }); + + it("swallows late rejection after abort (no unhandled rejection)", async () => { + const controller = new AbortController(); + let rejectTool!: (e: unknown) => void; + const tool = makeTool({ + inputSchema: undefined, + execute: () => + new Promise((_resolve, reject) => { + rejectTool = reject; + }), + }); + const promise = runTool(tool, "{}", controller.signal); + controller.abort(); + await expect(promise).rejects.toThrow(expect.objectContaining({ name: "AbortError" })); + rejectTool(new Error("late failure")); + await new Promise((r) => setTimeout(r, 0)); + }); + + it("rejects UnknownError (message preserved) when the tool fails without abort", async () => { + const tool = makeTool({ + inputSchema: undefined, + execute: () => { + throw new Error("handler broke"); + }, + }); + await expect(runTool(tool, "{}")).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + await expect(runTool(tool, "{}")).rejects.toThrow("handler broke"); + }); +}); diff --git a/src/polyfill/execute.ts b/src/polyfill/execute.ts new file mode 100644 index 0000000..2475c3f --- /dev/null +++ b/src/polyfill/execute.ts @@ -0,0 +1,100 @@ +import type { ToolDescriptor } from "../types"; +import { validateArgs } from "./validation"; + +function serializeResult(result: unknown): string { + const text = + typeof result === "object" && result !== null + ? JSON.stringify(result) // throws on cycles — handled by the caller + : String(result); + return text === "" ? "Operation succeeded" : text; +} + +/** + * Execute a registered tool the way native Chrome does (152–154 behavior): + * JSON-string or object input, per-execution AbortSignal forwarded to the + * tool, caller abort rejects with the caller signal's reason while the + * tool-side signal aborts with a generic AbortError, late settlement after + * abort is ignored, and results serialize to a string (objects via JSON, + * primitives via String, empty string → "Operation succeeded"). + * + * Deviation from native: input is validated against the tool's inputSchema + * (OperationError) — Chrome does not validate yet (spec issue #92). + */ +export function runTool( + tool: ToolDescriptor, + inputArguments: string | object, + callerSignal?: AbortSignal, +): Promise { + if (callerSignal?.aborted) { + return Promise.reject(callerSignal.reason); + } + + let parsed: unknown; + if (typeof inputArguments === "string") { + try { + parsed = JSON.parse(inputArguments); + } catch { + return Promise.reject(new DOMException("Failed to parse input arguments", "UnknownError")); + } + } else { + parsed = inputArguments; + } + if (typeof parsed !== "object" || parsed === null) { + return Promise.reject( + new DOMException("Input arguments must be a JSON object", "UnknownError"), + ); + } + + if (tool.inputSchema) { + try { + validateArgs(parsed as Record, tool.inputSchema); + } catch (thrown) { + return Promise.reject(thrown); + } + } + + const controller = new AbortController(); + + return new Promise((resolve, reject) => { + let settled = false; + + const onAbort = () => { + if (settled) return; + settled = true; + controller.abort(); // default reason → generic AbortError, matching Chrome + reject(callerSignal?.reason); + }; + callerSignal?.addEventListener("abort", onAbort, { once: true }); + + const onSettle = (result: unknown) => { + if (settled) return; // late settlement after abort — ignored + settled = true; + callerSignal?.removeEventListener("abort", onAbort); + try { + resolve(serializeResult(result)); + } catch { + reject(new DOMException("Tool result is not JSON-serializable", "UnknownError")); + } + }; + const onFail = (thrown: unknown) => { + if (settled) return; // late rejection after abort — ignored + settled = true; + callerSignal?.removeEventListener("abort", onAbort); + const message = thrown instanceof Error ? thrown.message : String(thrown); + reject(new DOMException(`Tool execution failed: ${message}`, "UnknownError")); + }; + + // Call tool.execute synchronously (not deferred behind an extra + // microtask) so it registers its own abort listener on `controller.signal` + // before a caller abort dispatched in the same synchronous turn can reach + // it. A synchronous throw is caught and routed through the same failure + // path as an async rejection. + try { + Promise.resolve( + tool.execute(parsed as Record, { signal: controller.signal }), + ).then(onSettle, onFail); + } catch (thrown) { + onFail(thrown); + } + }); +} From a68b111e31af32e6f712595c4611587ae67fbd85 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 20:34:38 -0400 Subject: [PATCH 07/12] feat: add getTools()/executeTool() consumer API to the polyfill --- src/polyfill/__tests__/consumer-api.test.ts | 164 ++++++++++++++++++++ src/polyfill/index.ts | 59 ++++++- src/polyfill/registry.ts | 5 + 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/polyfill/__tests__/consumer-api.test.ts diff --git a/src/polyfill/__tests__/consumer-api.test.ts b/src/polyfill/__tests__/consumer-api.test.ts new file mode 100644 index 0000000..201dac2 --- /dev/null +++ b/src/polyfill/__tests__/consumer-api.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { CallToolResult, ModelContext, RegisteredTool, ToolDescriptor } from "../../types"; +import { cleanupPolyfill, installPolyfill } from ".."; + +function makeTool(overrides?: Partial): ToolDescriptor { + return { + name: "consumer_tool", + description: "consumer test tool", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + ...overrides, + }; +} + +// The polyfill always implements getTools/executeTool, unlike the base +// ModelContext type (where they're optional for spec parity), so this +// narrows the helper's return type once instead of asserting at every call site. +type InstalledModelContext = ModelContext & + Required>; + +function mc(): InstalledModelContext { + const m = document.modelContext; + if (!m) throw new Error("polyfill not installed"); + return m as InstalledModelContext; +} + +afterEach(() => { + cleanupPolyfill(); +}); + +describe("document.modelContext.getTools (polyfill)", () => { + it("returns registered tools sorted by name with object inputSchema and defaults", async () => { + installPolyfill(); + await mc().registerTool(makeTool({ name: "b_tool" })); + await mc().registerTool(makeTool({ name: "a_tool" })); + const tools = await mc().getTools(); + expect(tools.map((t) => t.name)).toEqual(["a_tool", "b_tool"]); + expect(typeof tools[0].inputSchema).toBe("object"); + expect(tools[0].title).toBe(""); + expect(tools[0].description).toBe("consumer test tool"); + expect(tools[0].origin).toBe(location.origin); + expect(tools[0].window).toBe(window); + }); + + it("returns a deep copy of inputSchema (mutation does not leak back)", async () => { + installPolyfill(); + await mc().registerTool(makeTool()); + const [first] = await mc().getTools(); + (first.inputSchema as Record).type = "mutated"; + const [second] = await mc().getTools(); + expect((second.inputSchema as Record).type).toBe("object"); + }); + + it("returns fresh objects on every call", async () => { + installPolyfill(); + await mc().registerTool(makeTool()); + const [a] = await mc().getTools(); + const [b] = await mc().getTools(); + expect(a).not.toBe(b); + }); + + it("preserves title and annotations when registered", async () => { + installPolyfill(); + await mc().registerTool(makeTool({ title: "Nice Tool", annotations: { readOnlyHint: true } })); + const [tool] = await mc().getTools(); + expect(tool.title).toBe("Nice Tool"); + expect(tool.annotations).toEqual({ readOnlyHint: true }); + }); + + it("rejects SecurityError for an untrustworthy fromOrigins entry", async () => { + installPolyfill(); + await expect(mc().getTools({ fromOrigins: ["http://evil.example"] })).rejects.toThrow( + expect.objectContaining({ name: "SecurityError" }), + ); + }); +}); + +describe("document.modelContext.executeTool (polyfill)", () => { + it("executes by RegisteredTool and resolves the JSON result", async () => { + installPolyfill(); + await mc().registerTool(makeTool()); + const [tool] = await mc().getTools(); + const raw = await mc().executeTool(tool, '{"query":"x"}'); + expect(JSON.parse(raw as string)).toEqual({ content: [{ type: "text", text: "ok" }] }); + }); + + it("accepts an object inputArguments", async () => { + installPolyfill(); + await mc().registerTool(makeTool()); + const [tool] = await mc().getTools(); + const raw = await mc().executeTool(tool, { query: "x" }); + expect(JSON.parse(raw as string)).toEqual({ content: [{ type: "text", text: "ok" }] }); + }); + + it("rejects UnknownError for a stale/unregistered tool", async () => { + installPolyfill(); + const stale: RegisteredTool = { name: "ghost", description: "gone" }; + await expect(mc().executeTool(stale, "{}")).rejects.toThrow( + expect.objectContaining({ name: "UnknownError" }), + ); + }); + + it("forwards the caller signal: tool-side signal aborts, caller gets its reason", async () => { + installPolyfill(); + let toolAborted = false; + await mc().registerTool( + makeTool({ + inputSchema: undefined, + execute: (_input, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + toolAborted = true; + reject(signal.reason); + }, + { once: true }, + ); + }), + }), + ); + const [tool] = await mc().getTools(); + const controller = new AbortController(); + const reason = new Error("cancel it"); + const promise = mc().executeTool(tool, "{}", { signal: controller.signal }); + controller.abort(reason); + await expect(promise).rejects.toBe(reason); + expect(toolAborted).toBe(true); + }); + + it("keeps an in-flight execution alive when the tool is unregistered mid-flight", async () => { + installPolyfill(); + const registration = new AbortController(); + let resolveTool!: (r: CallToolResult) => void; + let toolSignalAborted = false; + await mc().registerTool( + makeTool({ + inputSchema: undefined, + execute: (_input, { signal }) => { + signal.addEventListener("abort", () => { + toolSignalAborted = true; + }); + return new Promise((resolve) => { + resolveTool = resolve; + }); + }, + }), + { signal: registration.signal }, + ); + const [tool] = await mc().getTools(); + const pending = mc().executeTool(tool, "{}"); + await Promise.resolve(); // let execute start + registration.abort(); // unregister mid-flight (Chrome 153.0.8008+ behavior) + resolveTool({ content: [{ type: "text", text: "late-ok" }] }); + const raw = await pending; + expect(JSON.parse(raw as string).content[0].text).toBe("late-ok"); + expect(toolSignalAborted).toBe(false); + expect((await mc().getTools()).map((t) => t.name)).not.toContain("consumer_tool"); + }); +}); diff --git a/src/polyfill/index.ts b/src/polyfill/index.ts index 0f00498..9f5fab9 100644 --- a/src/polyfill/index.ts +++ b/src/polyfill/index.ts @@ -1,17 +1,74 @@ -import type { ModelContext } from "../types"; +import type { + ExecuteToolOptions, + InputSchema, + ModelContext, + ModelContextGetToolOptions, + RegisteredTool, +} from "../types"; +import { runTool } from "./execute"; import { createRegistry, type RegistryInternal } from "./registry"; import { createTestingShim } from "./testing-shim"; +import { isPotentiallyTrustworthyOrigin } from "./validation"; class PolyfillModelContext extends EventTarget { readonly __isWebMCPPolyfill = true as const; registerTool: ModelContext["registerTool"]; + #registry: RegistryInternal; #ontoolchange: ((ev: Event) => unknown) | null = null; constructor(registry: RegistryInternal) { super(); + this.#registry = registry; this.registerTool = registry.registerTool; } + getTools(options?: ModelContextGetToolOptions): Promise { + if (options?.fromOrigins) { + for (const origin of options.fromOrigins) { + if (!isPotentiallyTrustworthyOrigin(origin)) { + return Promise.reject( + new DOMException( + "Only secure origins are allowed in the fromOrigins list.", + "SecurityError", + ), + ); + } + } + } + // Single-document polyfill: every registered tool is same-origin, so + // fromOrigins never filters anything here. + const tools = Array.from(this.#registry.getTools().values()) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map( + (tool): RegisteredTool => ({ + name: tool.name, + title: tool.title ?? "", + description: tool.description, + ...(tool.inputSchema && { + // JSON round-trip: a deep copy, matching how Chrome 154 materializes + // the object from the schema captured at registration. + inputSchema: JSON.parse(JSON.stringify(tool.inputSchema)) as InputSchema, + }), + ...(tool.annotations && { annotations: { ...tool.annotations } }), + window, + origin: location.origin, + }), + ); + return Promise.resolve(tools); + } + + executeTool( + tool: RegisteredTool, + inputArguments: string | object, + options?: ExecuteToolOptions, + ): Promise { + const registered = tool ? this.#registry.get(tool.name) : undefined; + if (!registered) { + return Promise.reject(new DOMException(`Tool "${tool?.name}" not found`, "UnknownError")); + } + return runTool(registered, inputArguments, options?.signal); + } + get ontoolchange(): ((ev: Event) => unknown) | null { return this.#ontoolchange; } diff --git a/src/polyfill/registry.ts b/src/polyfill/registry.ts index a4e6a7b..6967152 100644 --- a/src/polyfill/registry.ts +++ b/src/polyfill/registry.ts @@ -4,6 +4,7 @@ import { isPotentiallyTrustworthyOrigin, isValidToolName } from "./validation"; export interface RegistryInternal { registerTool(tool: ToolDescriptor, options?: RegisterToolOptions): Promise; getTools(): ReadonlyMap; + get(name: string): ToolDescriptor | undefined; addChangeListener(callback: () => void): () => void; } @@ -96,6 +97,10 @@ export function createRegistry(): RegistryInternal { return tools; }, + get(name: string): ToolDescriptor | undefined { + return tools.get(name); + }, + addChangeListener(callback: () => void): () => void { listeners.add(callback); return () => { From d02fd097373de82261513f947cb1c4f7511d38cd Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 20:40:45 -0400 Subject: [PATCH 08/12] test: prove deep copy of inputSchema with a nested mutation --- src/polyfill/__tests__/consumer-api.test.ts | 30 ++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/polyfill/__tests__/consumer-api.test.ts b/src/polyfill/__tests__/consumer-api.test.ts index 201dac2..c946144 100644 --- a/src/polyfill/__tests__/consumer-api.test.ts +++ b/src/polyfill/__tests__/consumer-api.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; -import type { CallToolResult, ModelContext, RegisteredTool, ToolDescriptor } from "../../types"; +import type { + CallToolResult, + InputSchema, + InputSchemaProperty, + ModelContext, + RegisteredTool, + ToolDescriptor, +} from "../../types"; import { cleanupPolyfill, installPolyfill } from ".."; function makeTool(overrides?: Partial): ToolDescriptor { @@ -22,6 +29,11 @@ function makeTool(overrides?: Partial): ToolDescriptor { type InstalledModelContext = ModelContext & Required>; +// Narrows RegisteredTool["inputSchema"] to the shape makeTool() actually +// registers, so the deep-copy test can reach the nested "query" property +// without an `as any` or non-null assertion. +type SchemaWithQuery = InputSchema & { properties: { query: InputSchemaProperty } }; + function mc(): InstalledModelContext { const m = document.modelContext; if (!m) throw new Error("polyfill not installed"); @@ -50,9 +62,16 @@ describe("document.modelContext.getTools (polyfill)", () => { installPolyfill(); await mc().registerTool(makeTool()); const [first] = await mc().getTools(); - (first.inputSchema as Record).type = "mutated"; + const firstSchema = first.inputSchema as SchemaWithQuery; + // Mutate a nested node, not just a top-level key — a shallow `{ ...schema }` + // copy would survive a top-level mutation identically, so only a nested + // mutation actually proves the copy is deep. + firstSchema.type = "mutated"; + firstSchema.properties.query.type = "mutated"; const [second] = await mc().getTools(); - expect((second.inputSchema as Record).type).toBe("object"); + const secondSchema = second.inputSchema as SchemaWithQuery; + expect(secondSchema.type).toBe("object"); + expect(secondSchema.properties.query.type).toBe("string"); }); it("returns fresh objects on every call", async () => { @@ -137,10 +156,12 @@ describe("document.modelContext.executeTool (polyfill)", () => { const registration = new AbortController(); let resolveTool!: (r: CallToolResult) => void; let toolSignalAborted = false; + let started = false; await mc().registerTool( makeTool({ inputSchema: undefined, execute: (_input, { signal }) => { + started = true; signal.addEventListener("abort", () => { toolSignalAborted = true; }); @@ -153,7 +174,8 @@ describe("document.modelContext.executeTool (polyfill)", () => { ); const [tool] = await mc().getTools(); const pending = mc().executeTool(tool, "{}"); - await Promise.resolve(); // let execute start + await Promise.resolve(); // flush the microtask that starts execute + expect(started).toBe(true); registration.abort(); // unregister mid-flight (Chrome 153.0.8008+ behavior) resolveTool({ content: [{ type: "text", text: "late-ok" }] }); const raw = await pending; From 7f9eebaa1dd84f0991ed33fe4e955d0abb89dfa0 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 20:44:59 -0400 Subject: [PATCH 09/12] feat: delegate testing shim to the execution engine; deprecate it --- src/polyfill/__tests__/testing-shim.test.ts | 32 +++++++--- src/polyfill/testing-shim.ts | 67 +++++---------------- 2 files changed, 40 insertions(+), 59 deletions(-) diff --git a/src/polyfill/__tests__/testing-shim.test.ts b/src/polyfill/__tests__/testing-shim.test.ts index daf0d24..5775ca9 100644 --- a/src/polyfill/__tests__/testing-shim.test.ts +++ b/src/polyfill/__tests__/testing-shim.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { CallToolResult, ToolDescriptor } from "../../types"; +import { _resetWarnings } from "../../utils/warn"; import { createRegistry } from "../registry"; import { createTestingShim } from "../testing-shim"; @@ -63,20 +64,22 @@ describe("createTestingShim", () => { expect(handler.mock.calls[0][0]).toEqual({ query: "hello" }); }); - it("calls execute with a single input argument (no client)", async () => { + it("calls execute with input and an options object carrying an AbortSignal", async () => { + // Chrome 153+ shape: execute(input, { signal }). const registry = createRegistry(); const shim = createTestingShim(registry); - let argCount = -1; + let args: unknown[] = []; await registry.registerTool({ name: "arity_tool", description: "checks arity", - execute: (...args: unknown[]) => { - argCount = args.length; + execute: (...a: unknown[]) => { + args = a; return { content: [{ type: "text", text: "ok" }] }; }, }); await shim.executeTool("arity_tool", "{}"); - expect(argCount).toBe(1); + expect(args).toHaveLength(2); + expect((args[1] as { signal: AbortSignal }).signal).toBeInstanceOf(AbortSignal); }); it("returns stringified CallToolResult", async () => { @@ -185,7 +188,9 @@ describe("createTestingShim", () => { ).rejects.toThrow(expect.objectContaining({ name: "AbortError" })); }); - it("does not produce unhandled rejection when handler aborts then throws", async () => { + it("rejects with the abort reason (not the handler error) when handler aborts then throws", async () => { + // Chrome 152+: once aborted, the caller sees the abort reason; the + // tool's late failure is discarded. No unhandled rejection either way. const controller = new AbortController(); const { shim } = setup([ makeTool({ @@ -198,7 +203,7 @@ describe("createTestingShim", () => { await expect( shim.executeTool("test_tool", '{"query":"x"}', { signal: controller.signal }), - ).rejects.toThrow("handler threw after aborting"); + ).rejects.toThrow(expect.objectContaining({ name: "AbortError" })); }); it("rejects immediately with AbortError for pre-aborted signal", async () => { @@ -210,6 +215,19 @@ describe("createTestingShim", () => { shim.executeTool("test_tool", '{"query":"x"}', { signal: controller.signal }), ).rejects.toThrow(expect.objectContaining({ name: "AbortError" })); }); + + it("warns once about deprecation", async () => { + _resetWarnings(); + const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { shim } = setup(); + await shim.executeTool("test_tool", '{"query":"x"}'); + shim.listTools(); + const deprecationWarns = spy.mock.calls.filter( + (c) => typeof c[0] === "string" && c[0].includes("modelContextTesting is deprecated"), + ); + expect(deprecationWarns).toHaveLength(1); + spy.mockRestore(); + }); }); describe("registerToolsChangedCallback", () => { diff --git a/src/polyfill/testing-shim.ts b/src/polyfill/testing-shim.ts index eb6b1f5..0fb0fb9 100644 --- a/src/polyfill/testing-shim.ts +++ b/src/polyfill/testing-shim.ts @@ -1,16 +1,19 @@ -import type { - CallToolResult, - MaybePromise, - ModelContextTesting, - ModelContextTestingExecuteToolOptions, -} from "../types"; +import type { ModelContextTesting, ModelContextTestingExecuteToolOptions } from "../types"; +import { warnOnce } from "../utils/warn"; +import { runTool } from "./execute"; import type { RegistryInternal } from "./registry"; -import { validateArgs } from "./validation"; +const DEPRECATION_KEY = "modelContextTesting-deprecated"; +const DEPRECATION_MSG = + "navigator.modelContextTesting is deprecated and will be removed in webmcp-react 0.4.0. " + + "Use document.modelContext.getTools() / executeTool() instead."; + +/** @deprecated Kept for one release as a wrapper over the modelContext consumer API. */ export function createTestingShim(registry: RegistryInternal): ModelContextTesting { let offChange: (() => void) | null = null; return { listTools() { + warnOnce(DEPRECATION_KEY, DEPRECATION_MSG); return Array.from(registry.getTools().values()).map((tool) => ({ name: tool.name, description: tool.description, @@ -23,65 +26,25 @@ export function createTestingShim(registry: RegistryInternal): ModelContextTesti inputArgsJson: string, options?: ModelContextTestingExecuteToolOptions, ): Promise { - const tool = registry.getTools().get(toolName); + warnOnce(DEPRECATION_KEY, DEPRECATION_MSG); + const tool = registry.get(toolName); if (!tool) { throw new DOMException(`Tool "${toolName}" not found`, "NotFoundError"); } - if (options?.signal?.aborted) { - throw new DOMException("Tool execution was aborted", "AbortError"); - } - + // This legacy surface keeps its stricter, historical input errors + // (OperationError; arrays rejected) — the engine itself is looser. let parsed: unknown; try { parsed = JSON.parse(inputArgsJson); } catch { throw new DOMException("Invalid JSON input", "OperationError"); } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new DOMException("Input must be a JSON object", "OperationError"); } - if (tool.inputSchema) { - validateArgs(parsed as Record, tool.inputSchema); - } - - const signal = options?.signal; - let onAbort: (() => void) | undefined; - let abortPromise: Promise | undefined; - if (signal) { - const abort = { fire: () => {} }; - const raw = new Promise((_, reject) => { - abort.fire = () => reject(new DOMException("Tool execution was aborted", "AbortError")); - }); - raw.catch(() => {}); - abortPromise = raw; - onAbort = abort.fire; - signal.addEventListener("abort", onAbort); - if (signal.aborted) { - onAbort(); - } - } - - try { - // This deprecated shim predates the two-argument execute(input, options) - // signature and intentionally keeps calling with a single argument. - const legacyExecute = tool.execute as ( - input: Record, - ) => MaybePromise; - const resultPromise = Promise.resolve(legacyExecute(parsed as Record)); - - const result = abortPromise - ? await Promise.race([abortPromise, resultPromise]) - : await resultPromise; - - return JSON.stringify(result); - } finally { - if (onAbort && signal) { - signal.removeEventListener("abort", onAbort); - } - } + return runTool(tool, parsed as Record, options?.signal); }, registerToolsChangedCallback(callback: () => void) { From 77f86c4f695c92e1a54c20be0666c361130792e8 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Fri, 21 Aug 2026 20:51:50 -0400 Subject: [PATCH 10/12] =?UTF-8?q?docs:=200.3.0=20=E2=80=94=20execution=20s?= =?UTF-8?q?ignals,=20consumer=20API,=20modelContextTesting=20deprecation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 12 ++++++++--- CHANGELOG.md | 37 ++++++++++++++++++++++++++++++++- README.md | 13 +++++++++++- docs/api.md | 32 +++++++++++++++++++++++----- package.json | 2 +- skills/webmcp-add-tool/SKILL.md | 6 +++--- skills/webmcp-setup/SKILL.md | 8 ++++--- 7 files changed, 93 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a9517e..168f2c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ src/ ← core library (your focus) ├── polyfill/ ← document.modelContext polyfill │ ├── index.ts ← installPolyfill / cleanupPolyfill + polyfill marker │ ├── registry.ts ← in-memory tool storage -│ ├── testing-shim.ts ← simulates MCP client calls +│ ├── execute.ts ← shared execution engine (signals, serialization, errors) +│ ├── testing-shim.ts ← DEPRECATED wrapper over execute.ts (removed in 0.4.0) │ └── validation.ts ← input validation against JSON Schema └── utils/ ├── schema.ts ← Zod → JSON Schema conversion + schema fingerprinting @@ -66,9 +67,14 @@ These patterns look like they could be simplified but exist for specific reasons **Native API detection**: The polyfill checks for native `document.modelContext` (document-only — it does not read `navigator.modelContext`) and skips installation if it exists. Don't remove this check — Chrome is shipping native WebMCP support. -**AbortSignal-only unregistration**: There is no `unregisterTool`. Tools are removed by aborting the `AbortSignal` passed to `registerTool`. `useMcpTool` aborts its controller on cleanup; the registry's abort listener removes the tool. Don't reintroduce an imperative unregister method. +**AbortSignal-only unregistration**: There is no `unregisterTool`. Tools are removed by aborting the `AbortSignal` passed to `registerTool`. `useMcpTool` aborts its controller on cleanup; the registry's abort listener removes the tool. Don't reintroduce an imperative unregister method. Unregistration does not cancel in-flight executions (Chrome 153.0.8008.0+); they run to completion. -**Single-arg execute/handler**: `descriptor.execute(input)` and the user `handler(args)` take a single argument. There is no `ModelContextClient` second argument. Both execution paths must stay mirrored. +**Two-arg execute/handler**: `descriptor.execute(input, { signal })` and the user +`handler(args, ctx)` receive the execution `AbortSignal` as their second argument (Chrome +153+ shape). There is still no `ModelContextClient`. A missing second argument at runtime +(Chrome ≤152) is substituted with a never-aborting signal. Both execution paths must stay +mirrored — they share `runHandler` in `useMcpTool.ts` and `runTool` in +`polyfill/execute.ts`. ## Testing diff --git a/CHANGELOG.md b/CHANGELOG.md index bc98176..ab25a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,39 @@ All notable changes to `webmcp-react` are documented here. The format is based o [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 0.3.0 + +Tracks Chrome 152–154 WebMCP changes: execution AbortSignals, the +`document.modelContext` consumer API, and the removal of +`navigator.modelContextTesting` from native Chrome. + +### Added + +- **Handlers receive an execution `AbortSignal`.** Handlers are now called as + `handler(args, { signal })`; on Chrome 153.0.8007.0+ the signal aborts when the agent or + user cancels the call — pass it to `fetch()` and other cancellable work. On Chrome ≤152 + (and for bare `execute(args)` calls) the library substitutes a never-aborting signal, so + the second argument is always safe to use. The hook's `execute(input?, { signal }?)` + accepts a caller signal too. Existing one-argument handlers keep working unchanged. +- **Polyfill consumer API.** `document.modelContext.getTools()` and + `executeTool(tool, inputArguments, { signal }?)`, matching native Chrome: + `RegisteredTool.inputSchema` is a deep-copied object (Chrome 154.0.8014.0+ shape), + `inputArguments` may be a JSON string or an object, execution failures reject with + `UnknownError`, aborts reject with the signal's reason, and unregistering a tool no + longer cancels in-flight executions (Chrome 153.0.8008.0+ behavior). The polyfill + additionally validates input against `inputSchema` (`OperationError`) — native Chrome + does not validate yet. +- New exported types: `ToolExecuteCallbackOptions`, `ExecuteToolOptions`, + `RegisteredTool`, `ModelContextGetToolOptions`. ### Changed +- **Abort is cancellation, not error.** When an execution's signal aborts and the handler + rejects, the hook clears `isExecuting` but leaves `state.error` untouched and does not + fire `onError`. +- The testing shim's abort rejections now use the signal's abort reason and its tool + failures reject with `UnknownError` (Chrome 152+ parity); its `OperationError` input + errors and `NotFoundError` are unchanged. - **Already-aborted `AbortSignal` now rejects.** The polyfill's `registerTool` rejects with the signal's abort reason when handed an already-aborted signal, matching WebMCP spec PR #202 and native Chrome 152.0.7943.0. Previously it resolved as a no-op, matching native Chrome 151. @@ -17,6 +46,12 @@ All notable changes to `webmcp-react` are documented here. The format is based o - Docs and npm keywords now reference `document.modelContext`; `navigator.modelContext` was removed from Chrome as of 152.0.7943.0 (the library itself migrated in 0.2.0). +### Deprecated + +- **`navigator.modelContextTesting`.** Native Chrome removed it in 152.0.7940.0. The + polyfill's shim now delegates to the same engine as `document.modelContext.executeTool()` + and warns once in dev. It will be removed in webmcp-react 0.4.0. + ## 0.2.0 Realigns the library with the current [WebMCP](https://github.com/webmachinelearning/webmcp) diff --git a/README.md b/README.md index 3ee88b1..8f1b736 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ function SearchTool() { name: "search", description: "Search the catalog", input: z.object({ query: z.string() }), - handler: async ({ query }) => ({ + handler: async ({ query }, { signal }) => ({ content: [{ type: "text", text: `Results for: ${query}` }], }), }); @@ -191,6 +191,17 @@ useMcpTool({ Works with Next.js, Remix, and any server-rendering framework out of the box. The build includes a `"use client"` banner, so no extra configuration is needed. +## What's new in 0.3.0 + +- **Handlers get an execution `AbortSignal`**: `handler(args, { signal })`. Chrome 153+ + aborts it when the agent cancels the call; on older Chrome the library substitutes a + never-aborting signal, so `fetch(url, { signal })` is always safe. One-argument handlers + keep working. +- **Consumer API in the polyfill**: `document.modelContext.getTools()` / + `executeTool(tool, args, { signal }?)` — the same surface native Chrome 152+ ships. +- **`navigator.modelContextTesting` is deprecated** (removed from native Chrome in 152; + removed from this library in 0.4.0). + ## Breaking changes in 0.2.0 0.2.0 realigns the library with the current [WebMCP](https://github.com/webmachinelearning/webmcp) spec. If you're upgrading from 0.1.0: diff --git a/docs/api.md b/docs/api.md index 8100c21..fc2f7d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # API Reference -This library targets the current [WebMCP](https://github.com/webmachinelearning/webmcp) spec. The registration API lives on `document.modelContext` (an `EventTarget`), and the testing/consumer API lives on `navigator.modelContextTesting`. +This library targets the current [WebMCP](https://github.com/webmachinelearning/webmcp) spec. Both the registration and consumer APIs live on `document.modelContext` (an `EventTarget`): `registerTool` on the registration side, `getTools()`/`executeTool()` on the consumer side. `navigator.modelContextTesting` is a deprecated wrapper over the same consumer engine. ## `` @@ -43,11 +43,11 @@ Registers a tool on `document.modelContext`. Automatically unregisters on unmoun | `output` | `z.ZodObject` | Optional Zod schema for outputs (library extension; see below) | | `annotations` | `ToolAnnotations` | Optional behavior hints (`readOnlyHint`, `untrustedContentHint`) | | `exposedTo` | `string[]` | Optional list of trustworthy origins this tool is exposed to across frames | -| `handler` | `(args) => CallToolResult \| Promise` | Tool implementation. Receives a single argument (the parsed input) | +| `handler` | `(args, ctx) => CallToolResult \| Promise` | Tool implementation. Receives the parsed input and `ctx: { signal: AbortSignal }`; the signal aborts when the agent cancels the execution (Chrome 153+; otherwise a never-aborting substitute) | | `onSuccess` | `(result) => void` | Optional callback on success | | `onError` | `(error) => void` | Optional callback on error | -The `handler` takes a **single argument** — the validated input object. There is no second `client` argument. +The `handler` receives the validated input object and a second `ctx` argument containing the execution `AbortSignal`. Handlers that declare a single parameter keep working. ### JSON Schema config @@ -80,11 +80,20 @@ const { state, execute, reset } = useMcpTool({ ... }); | `state.lastResult` | `CallToolResult \| null` | Most recent result | | `state.error` | `Error \| null` | Most recent error | | `state.executionCount` | `number` | Total successful executions | -| `execute(input?)` | `(input?) => Promise` | Manually invoke the tool | +| `execute(input?, { signal }?)` | `(input?, options?) => Promise` | Manually invoke the tool | | `reset()` | `() => void` | Reset state to initial values | `execute()` (the UI/direct path) throws if validation or handler logic fails. The agent/testing-shim path returns a `CallToolResult` with `isError: true` instead. Both paths update the same reactive state and fire the same `onSuccess`/`onError` callbacks. +### Cancellation + +Each execution gets its own `AbortSignal`, passed to the handler as `ctx.signal`. On +Chrome 153.0.8007.0+ (and via the polyfill's `executeTool`) it aborts when the caller +cancels. When an aborted execution's handler rejects, the hook treats it as +**cancellation**: `isExecuting` clears, but `state.error` stays untouched and `onError` +does not fire. Unregistering a tool (unmount) does **not** cancel in-flight executions +(Chrome 153.0.8008.0+ behavior). + ## Results: `CallToolResult` Handlers always return a `CallToolResult` with a `content` array — including error results, which set `isError: true`. This is a deliberate library convention layered over the spec's looser return type, so results bridge cleanly to desktop MCP clients. @@ -104,7 +113,20 @@ interface CallToolResult { When native WebMCP is unavailable, the provider installs a polyfill that exposes: - `document.modelContext` — the registration API (an `EventTarget`). `registerTool(tool, options?)` returns a `Promise` that **rejects** on invalid input (see below). Unregistration is **AbortSignal-only** — pass `{ signal }` and abort it to remove the tool. There is no `unregisterTool`. -- `navigator.modelContextTesting` — the consumer/testing API (`listTools()`, `executeTool(name, argsJson, options?)`, `registerToolsChangedCallback(cb)`, `getCrossDocumentScriptToolResult()`). Browser extensions and tests use this to discover and invoke tools. +- `document.modelContext.getTools(options?)` / `executeTool(tool, inputArguments, options?)` + — the consumer API (same shape as native Chrome). `getTools()` resolves sorted, fresh + `RegisteredTool` objects whose `inputSchema` is a deep-copied **object**; + `executeTool` accepts a JSON string or object input, forwards `options.signal` into the + tool's execution signal, rejects `UnknownError` on failure, and — unlike native Chrome — + validates input against `inputSchema` (`OperationError`). +- `navigator.modelContextTesting` — **deprecated** wrapper over the same engine + (`listTools()` keeps returning a JSON-string `inputSchema`); removed in 0.4.0. + +| Chrome | Behavior this library tracks | +| --- | --- | +| ≤152 | `execute(input)` — no tool-side signal (the library substitutes one); `navigator.modelContextTesting` removed in 152.0.7940.0 | +| 153 | `execute(input, { signal })`; unregistration no longer cancels in-flight executions (153.0.8008.0+) | +| 154 | `RegisteredTool.inputSchema` is an object (was a JSON string) | The native API is detected by reading `document.modelContext` only; the polyfill marks itself with `__isWebMCPPolyfill` so native support short-circuits installation. diff --git a/package.json b/package.json index 64df521..9563dcf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webmcp-react", - "version": "0.2.0", + "version": "0.3.0", "description": "React hooks for exposing your app's functionality as WebMCP tools - transport-agnostic, SSR-safe, Strict Mode safe, W3C spec-aligned", "type": "module", "main": "./dist/index.cjs", diff --git a/skills/webmcp-add-tool/SKILL.md b/skills/webmcp-add-tool/SKILL.md index 52adcdb..42b724b 100644 --- a/skills/webmcp-add-tool/SKILL.md +++ b/skills/webmcp-add-tool/SKILL.md @@ -33,9 +33,9 @@ export function MyTool() { // Define each input field with .describe() for AI context query: z.string().describe("The search query"), }), - handler: async ({ query }) => { - // Implement tool logic here - const result = await doSomething(query); + handler: async ({ query }, { signal }) => { + // signal aborts if the agent cancels — forward it to cancellable work + const result = await doSomething(query, { signal }); return { content: [{ type: "text", text: JSON.stringify(result) }], }; diff --git a/skills/webmcp-setup/SKILL.md b/skills/webmcp-setup/SKILL.md index a8d39df..6777830 100644 --- a/skills/webmcp-setup/SKILL.md +++ b/skills/webmcp-setup/SKILL.md @@ -91,9 +91,11 @@ export function GreetTool() { input: z.object({ name: z.string().describe("The name to greet"), }), - handler: async ({ name }) => ({ - content: [{ type: "text", text: `Hello, ${name}!` }], - }), + handler: async ({ name }, { signal }) => { + // signal aborts if the agent cancels — forward it to cancellable work + const res = await fetch(`/api/greet?name=${encodeURIComponent(name)}`, { signal }); + return { content: [{ type: "text", text: await res.text() }] }; + }, }); return null; } From 8f049378b652127bd9af69b88c102fbde7fa80b3 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Sat, 22 Aug 2026 09:13:05 -0400 Subject: [PATCH 11/12] test: type-check the test files and drop non-null assertions tsconfig.json excludes src/**/__tests__, so the compile-time assertions in src/__tests__/type-compat.test.ts were never checked by any gate. Add tsconfig.test.json (extends the root config, includes all of src, wider lib for Array.prototype.at, vitest globals, noEmit) and run it from `pnpm typecheck` alongside the emit-shaped root check. Fix the nine type errors the new gate surfaces: type the vi.fn() mocks whose mock.calls[0][0] was a zero-length tuple, annotate the three handler destructures that lose Zod inference through the widened test-component prop, and route the ToolDescriptor -> Record cast through unknown. Replace the two `executeRef.current!` non-null assertions with a hoisted `const exec = executeRef.current as ExecuteFn` so `pnpm lint` is back to zero warnings; `?.` there would make `promise` possibly undefined. --- package.json | 2 +- src/__tests__/smoke.test.tsx | 2 +- src/hooks/__tests__/useMcpTool.test.tsx | 10 ++++++---- src/polyfill/__tests__/execute.test.ts | 6 ++++-- src/polyfill/__tests__/registry.test.ts | 2 +- src/polyfill/__tests__/testing-shim.test.ts | 7 +++++-- tsconfig.test.json | 16 ++++++++++++++++ 7 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 tsconfig.test.json diff --git a/package.json b/package.json index 9563dcf..6cd5a33 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "build": "tsup", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit", "lint": "biome check .", "lint:fix": "biome check --write .", "dev:playground": "pnpm --filter webmcp-react-playground dev", diff --git a/src/__tests__/smoke.test.tsx b/src/__tests__/smoke.test.tsx index 2b2217d..f35def8 100644 --- a/src/__tests__/smoke.test.tsx +++ b/src/__tests__/smoke.test.tsx @@ -156,7 +156,7 @@ describe("smoke: executeTool through testing shim", () => { name: "zod_greet", description: "Zod greeter", input: z.object({ name: z.string() }), - handler: async ({ name }) => makeResult(`hello ${name}`), + handler: async ({ name }: Record) => makeResult(`hello ${name}`), }} /> , diff --git a/src/hooks/__tests__/useMcpTool.test.tsx b/src/hooks/__tests__/useMcpTool.test.tsx index 9379625..3042754 100644 --- a/src/hooks/__tests__/useMcpTool.test.tsx +++ b/src/hooks/__tests__/useMcpTool.test.tsx @@ -594,7 +594,7 @@ describe("Strict Mode safety", () => { name: "greet", description: "Say hello", input: z.object({ name: z.string() }), - handler: async ({ name }) => makeResult(`hello ${name}`), + handler: async ({ name }: Record) => makeResult(`hello ${name}`), }} /> @@ -940,7 +940,7 @@ describe("MCP integration", () => { name: "greet", description: "Say hello", input: z.object({ name: z.string() }), - handler: async ({ name }) => makeResult(`hello ${name}`), + handler: async ({ name }: Record) => makeResult(`hello ${name}`), }} />, ); @@ -1590,10 +1590,11 @@ describe("execution signal", () => { ); await waitForRegistration(); const controller = new AbortController(); + const exec = executeRef.current as ExecuteFn; let rejected: unknown = null; let promise!: Promise; act(() => { - promise = executeRef.current!({}, { signal: controller.signal }); + promise = exec({}, { signal: controller.signal }); promise.catch((e: unknown) => { rejected = e; }); @@ -1692,8 +1693,9 @@ describe("execution signal", () => { ); await waitForRegistration(); const controller = new AbortController(); // present but never aborted + const exec = executeRef.current as ExecuteFn; await act(async () => { - await executeRef.current!({}, { signal: controller.signal }).catch(() => {}); + await exec({}, { signal: controller.signal }).catch(() => {}); }); expect(onError).toHaveBeenCalledTimes(1); expect(getByTestId("error").textContent).toBe("genuine failure"); diff --git a/src/polyfill/__tests__/execute.test.ts b/src/polyfill/__tests__/execute.test.ts index 5a9f8a1..87fed12 100644 --- a/src/polyfill/__tests__/execute.test.ts +++ b/src/polyfill/__tests__/execute.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { CallToolResult, ToolDescriptor } from "../../types"; +import type { CallToolResult, ToolDescriptor, ToolExecuteCallbackOptions } from "../../types"; import { runTool } from "../execute"; function makeTool(overrides?: Partial): ToolDescriptor { @@ -33,7 +33,9 @@ describe("runTool", () => { }); it("accepts an object input without re-parsing", async () => { - const execute = vi.fn(async () => OK); + const execute = vi.fn( + async (_input: Record, _options: ToolExecuteCallbackOptions) => OK, + ); await runTool(makeTool({ execute }), { query: "hi" }); expect(execute.mock.calls[0][0]).toEqual({ query: "hi" }); }); diff --git a/src/polyfill/__tests__/registry.test.ts b/src/polyfill/__tests__/registry.test.ts index d91474a..b7bebd5 100644 --- a/src/polyfill/__tests__/registry.test.ts +++ b/src/polyfill/__tests__/registry.test.ts @@ -148,7 +148,7 @@ describe("createRegistry", () => { const tool = makeTool(); await registry.registerTool(tool); - (tool as Record).description = "mutated"; + (tool as unknown as Record).description = "mutated"; const stored = registry.getTools().get("test_tool"); expect(stored?.description).toBe("A test tool"); }); diff --git a/src/polyfill/__tests__/testing-shim.test.ts b/src/polyfill/__tests__/testing-shim.test.ts index 5775ca9..d246969 100644 --- a/src/polyfill/__tests__/testing-shim.test.ts +++ b/src/polyfill/__tests__/testing-shim.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { CallToolResult, ToolDescriptor } from "../../types"; +import type { CallToolResult, ToolDescriptor, ToolExecuteCallbackOptions } from "../../types"; import { _resetWarnings } from "../../utils/warn"; import { createRegistry } from "../registry"; import { createTestingShim } from "../testing-shim"; @@ -55,7 +55,10 @@ describe("createTestingShim", () => { describe("executeTool", () => { it("calls handler with parsed args", async () => { - const handler = vi.fn(async () => makeResult()); + const handler = vi.fn( + async (_input: Record, _options: ToolExecuteCallbackOptions) => + makeResult(), + ); const { shim } = setup([makeTool({ execute: handler })]); await shim.executeTool("test_tool", '{"query":"hello"}'); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..67cdacd --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + // Type-checks the test files that the root config excludes, so the + // compile-time assertions in src/__tests__/type-compat.test.ts are a real gate. + // Tests need a wider lib (Array.prototype.at) and vitest's globals. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vitest/globals"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From 97b775a24384dba72825eae2284b976bb9e96365 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Sat, 22 Aug 2026 09:13:12 -0400 Subject: [PATCH 12/12] docs: point testing guidance at the consumer API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md still named navigator.modelContextTesting as the external-call path in the architecture note and the Testing section, which would steer new tests at a surface that is removed in 0.4.0. Name document.modelContext.getTools() / executeTool() instead and mention the shim only as deprecated. Drop the unused `{ signal }` destructure from the README quick start — it trips noUnusedParameters for anyone copying the first snippet. The 0.3.0 section still shows the two-argument handler. --- AGENTS.md | 6 +++--- README.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 168f2c4..f3544ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ These patterns look like they could be simplified but exist for specific reasons **Schema fingerprinting** (`schemaFingerprint` in `utils/schema.ts`, used by `useMcpTool.ts`): useEffect deps use string fingerprints of schemas, not object references, to prevent infinite re-registration loops when schema objects are recreated each render. Don't switch to direct object comparison. -**Dual execution paths**: Tools execute via `execute()` (internal/UI calls) and via the testing shim (external MCP client calls). Both paths update the same reactive state and fire the same callbacks. Changes to one path must be mirrored in the other. +**Dual execution paths**: Tools execute via `execute()` (internal/UI calls) and via `document.modelContext.executeTool()` (external MCP client calls, native or polyfilled). Both paths update the same reactive state and fire the same callbacks. Changes to one path must be mirrored in the other. **Ref-wrapped config** (`configRef`, `handlerRef`, etc.): Refs wrap mutable config so the registration useEffect doesn't re-run on every render. These are not missed dependencies — they're intentional stability optimizations. @@ -81,10 +81,10 @@ mirrored — they share `runHandler` in `useMcpTool.ts` and `runTool` in - **Framework**: Vitest + React Testing Library + jsdom - **Location**: `__tests__/` directories adjacent to source files - **StrictMode**: All tests must pass under React StrictMode (double-mount behavior) -- **Testing shim**: `polyfill/testing-shim.ts` simulates external MCP client calls — use it in tests to verify the full registration → execution → state update cycle +- **External-call path**: `document.modelContext.getTools()` / `executeTool()` is the consumer API — use it in tests to verify the full registration → execution → state update cycle. `navigator.modelContextTesting` (`polyfill/testing-shim.ts`) delegates to the same engine but is deprecated and is removed in 0.4.0; don't write new tests against it - **Coverage areas**: Registration lifecycle, execution state, error handling, input validation, SSR safety, StrictMode compatibility -When adding features, write tests that cover both execution paths (direct `execute()` and testing shim `executeTool()`). +When adding features, write tests that cover both execution paths (direct `execute()` and `document.modelContext.executeTool()`). ## Code Style diff --git a/README.md b/README.md index 8f1b736..a837607 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ function SearchTool() { name: "search", description: "Search the catalog", input: z.object({ query: z.string() }), - handler: async ({ query }, { signal }) => ({ + handler: async ({ query }) => ({ content: [{ type: "text", text: `Results for: ${query}` }], }), });