diff --git a/.gitignore b/.gitignore index f4a9f04..14e4760 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ examples/nextjs/.next/ src/**/*.js examples/*/src/**/*.js docs/plans/ +tsconfig.vitest-temp.json diff --git a/AGENTS.md b/AGENTS.md index 0a7d482..2b6cb16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ mirrored — they share `runHandler` in `useMcpTool.ts` and `runTool` in - **Framework**: Vitest + React Testing Library + jsdom - **Location**: `__tests__/` directories adjacent to source files +- **Type tests**: `*.test-d.ts` files in the same directories, checked by vitest's typecheck mode against `tsconfig.test.json`. Use them when changing the public types in `src/types.ts` - **StrictMode**: All tests must pass under React StrictMode (double-mount behavior) - **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 2.0.0; don't write new tests against it - **Coverage areas**: Registration lifecycle, execution state, error handling, input validation, SSR safety, StrictMode compatibility diff --git a/CHANGELOG.md b/CHANGELOG.md index 27df9bc..b8805c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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 + +### Added + +- **`toolactivated` and `toolcancel` event types.** Chrome 156.0.8076.0 moves these events + from `window` to `document.modelContext` (WebMCP PR #245). `ModelContext` now declares + `ontoolactivated` / `ontoolcancel` and typed `addEventListener` / `removeEventListener` + overloads for them, with `toolchange` unchanged. Event names outside the map fall back + to the plain `EventTarget` signature instead of failing to compile. +- New exported types: `ToolActivatedEvent`, `ToolCancelEvent` (both `Event` plus a `toolName` + string) and `ModelContextEventMap`. + +### Compatibility + +- The polyfill still fires only `toolchange`. `toolactivated` and `toolcancel` are dispatched + by native Chrome; on Chrome ≤155 they fire on `window` rather than `document.modelContext`. + ## 1.2.0 ### Added diff --git a/docs/api.md b/docs/api.md index ed04197..4d77163 100644 --- a/docs/api.md +++ b/docs/api.md @@ -92,7 +92,8 @@ Chrome 153.0.8007.0+ (and via the polyfill's `executeTool`) it aborts when the c 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). +(Chrome 153.0.8008.0+ behavior). Native Chrome also announces starts and cancellations +through the `toolactivated` and `toolcancel` events described under [Events](#events). ## Results: `CallToolResult` @@ -128,6 +129,7 @@ When native WebMCP is unavailable, the provider installs a polyfill that exposes | 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) | | 155 | `executeTool` takes object inputs instead of JSON strings (155.0.8052.0+) | +| 156 | `toolactivated` and `toolcancel` fire on `document.modelContext` instead of `window` (156.0.8076.0+) | Pass an object to `executeTool`, using `{}` for tools without arguments. When options are omitted or `undefined`, omitting input or passing `undefined` defaults to a fresh `{}`. @@ -157,16 +159,28 @@ consumer code. This compatibility policy is separate from the deprecated testing The native API is detected by reading `document.modelContext` only; the polyfill marks itself with `__isWebMCPPolyfill` so native support short-circuits installation. -### `toolchange` event +### Events -`document.modelContext` is an `EventTarget` that fires a bare `toolchange` event (no `detail`) whenever the set of registered tools changes (register or unregister). Notifications are microtask-batched. Both styles are supported: +`document.modelContext` is an `EventTarget`. Three events are typed on it: + +| Event | Event type | Fires when | +| --- | --- | --- | +| `toolchange` | `Event` (no `detail`) | the set of registered tools changes (register or unregister). Notifications are microtask-batched. | +| `toolactivated` | `ToolActivatedEvent` | native Chrome begins executing a tool through `executeTool` | +| `toolcancel` | `ToolCancelEvent` | native Chrome cancels a pending execution, for example when the caller's `signal` aborts | + +`ToolActivatedEvent` and `ToolCancelEvent` extend `Event` with a `toolName` string. Both listener styles are supported, and `ModelContextEventMap` maps each event name to its event type: ```ts document.modelContext.addEventListener("toolchange", () => { /* ... */ }); -// or -document.modelContext.ontoolchange = () => { /* ... */ }; +document.modelContext.addEventListener("toolactivated", ({ toolName }) => { /* ... */ }); +document.modelContext.ontoolcancel = ({ toolName }) => { /* ... */ }; ``` +Chrome 156.0.8076.0 moved `toolactivated` and `toolcancel` from `window` to `document.modelContext`; on Chrome ≤155 they fire on `window`. Feature-detect with `"ontoolactivated" in document.modelContext`. Event names outside the map fall back to the plain `EventTarget` signature. + +The polyfill fires only `toolchange`. It does not dispatch `toolactivated` or `toolcancel`. + ### `registerTool` rejection cases `registerTool` rejects (with a `DOMException` or `TypeError`) when given: diff --git a/src/__tests__/model-context-events.test-d.ts b/src/__tests__/model-context-events.test-d.ts new file mode 100644 index 0000000..89cc5ce --- /dev/null +++ b/src/__tests__/model-context-events.test-d.ts @@ -0,0 +1,69 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { ToolActivatedEvent, ToolCancelEvent } from "../index"; + +type ModelContext = NonNullable; + +describe("ModelContext event types", () => { + it("types toolactivated listeners with ToolActivatedEvent", () => { + const mc = {} as ModelContext; + mc.addEventListener("toolactivated", (ev) => { + expectTypeOf(ev).toEqualTypeOf(); + }); + mc.removeEventListener("toolactivated", (ev) => { + expectTypeOf(ev).toEqualTypeOf(); + }); + }); + + it("types toolcancel listeners with ToolCancelEvent", () => { + const mc = {} as ModelContext; + mc.addEventListener("toolcancel", (ev) => { + expectTypeOf(ev).toEqualTypeOf(); + }); + mc.removeEventListener("toolcancel", (ev) => { + expectTypeOf(ev).toEqualTypeOf(); + }); + }); + + it("keeps toolchange listeners typed as a bare Event", () => { + const mc = {} as ModelContext; + mc.addEventListener("toolchange", (ev) => { + expectTypeOf(ev).toEqualTypeOf(); + }); + }); + + it("exposes ontoolactivated and ontoolcancel handler attributes", () => { + const mc = {} as ModelContext; + expectTypeOf(mc.ontoolactivated).toEqualTypeOf< + ((this: ModelContext, ev: ToolActivatedEvent) => unknown) | null + >(); + expectTypeOf(mc.ontoolcancel).toEqualTypeOf< + ((this: ModelContext, ev: ToolCancelEvent) => unknown) | null + >(); + mc.ontoolactivated = null; + mc.ontoolcancel = null; + }); + + it("exposes toolName on both events", () => { + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("still accepts event names it does not know about", () => { + const mc = {} as ModelContext; + mc.addEventListener("someotherevent", (ev) => { + expectTypeOf(ev).toEqualTypeOf(); + }); + mc.removeEventListener("someotherevent", null); + }); + + it("reaches the new members through the global document augmentation", () => { + document.modelContext?.addEventListener("toolactivated", (ev) => { + expectTypeOf(ev.toolName).toEqualTypeOf(); + }); + expectTypeOf(document.modelContext?.ontoolcancel).toEqualTypeOf< + ((this: ModelContext, ev: ToolCancelEvent) => unknown) | null | undefined + >(); + }); +}); diff --git a/src/index.ts b/src/index.ts index af4aa33..ee8ee02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,10 +5,13 @@ export type { ExecuteToolOptions, McpToolConfigJsonSchema, McpToolConfigZod, + ModelContextEventMap, ModelContextGetToolOptions, RegisteredTool, RegisterToolOptions, + ToolActivatedEvent, ToolAnnotations, + ToolCancelEvent, ToolExecuteCallbackOptions, ToolExecutionState, UseMcpToolReturn, diff --git a/src/types.ts b/src/types.ts index ae287de..02fbd91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -175,6 +175,22 @@ export interface ModelContextGetToolOptions { fromOrigins?: string[]; } +/** Fired at `document.modelContext` when a tool execution begins (Chrome 156.0.8076.0+). */ +export interface ToolActivatedEvent extends Event { + readonly toolName: string; +} + +/** Fired at `document.modelContext` when a pending tool execution is cancelled (Chrome 156.0.8076.0+). */ +export interface ToolCancelEvent extends Event { + readonly toolName: string; +} + +export interface ModelContextEventMap { + toolchange: Event; + toolactivated: ToolActivatedEvent; + toolcancel: ToolCancelEvent; +} + export interface ModelContext extends EventTarget { registerTool(tool: ToolDescriptor, options?: RegisterToolOptions): Promise; getTools?(options?: ModelContextGetToolOptions): Promise; @@ -188,14 +204,26 @@ export interface ModelContext extends EventTarget { options?: ExecuteToolOptions, ): Promise; ontoolchange: ((this: ModelContext, ev: Event) => unknown) | null; + ontoolactivated: ((this: ModelContext, ev: ToolActivatedEvent) => unknown) | null; + ontoolcancel: ((this: ModelContext, ev: ToolCancelEvent) => unknown) | null; + addEventListener( + type: K, + listener: (ev: ModelContextEventMap[K]) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; addEventListener( - type: "toolchange", - listener: (ev: Event) => unknown, + type: string, + listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, ): void; + removeEventListener( + type: K, + listener: (ev: ModelContextEventMap[K]) => unknown, + options?: boolean | EventListenerOptions, + ): void; removeEventListener( - type: "toolchange", - listener: (ev: Event) => unknown, + type: string, + listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions, ): void; } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..1796922 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/__tests__/**/*.test-d.ts"], + "exclude": [] +} diff --git a/vitest.config.ts b/vitest.config.ts index 06e82d1..285375e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,11 @@ export default defineConfig({ "extension/src/**/__tests__/**/*.test.{ts,tsx}", "examples/**/src/**/*.test.{ts,tsx}", ], + typecheck: { + enabled: true, + tsconfig: "./tsconfig.test.json", + include: ["src/**/__tests__/**/*.test-d.ts"], + }, setupFiles: ["src/__tests__/setup.ts"], passWithNoTests: true, },