Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ examples/nextjs/.next/
src/**/*.js
examples/*/src/**/*.js
docs/plans/
tsconfig.vitest-temp.json
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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 `{}`.
Expand Down Expand Up @@ -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:
Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/model-context-events.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expectTypeOf, it } from "vitest";
import type { ToolActivatedEvent, ToolCancelEvent } from "../index";

type ModelContext = NonNullable<Document["modelContext"]>;

describe("ModelContext event types", () => {
it("types toolactivated listeners with ToolActivatedEvent", () => {
const mc = {} as ModelContext;
mc.addEventListener("toolactivated", (ev) => {
expectTypeOf(ev).toEqualTypeOf<ToolActivatedEvent>();
});
mc.removeEventListener("toolactivated", (ev) => {
expectTypeOf(ev).toEqualTypeOf<ToolActivatedEvent>();
});
});

it("types toolcancel listeners with ToolCancelEvent", () => {
const mc = {} as ModelContext;
mc.addEventListener("toolcancel", (ev) => {
expectTypeOf(ev).toEqualTypeOf<ToolCancelEvent>();
});
mc.removeEventListener("toolcancel", (ev) => {
expectTypeOf(ev).toEqualTypeOf<ToolCancelEvent>();
});
});

it("keeps toolchange listeners typed as a bare Event", () => {
const mc = {} as ModelContext;
mc.addEventListener("toolchange", (ev) => {
expectTypeOf(ev).toEqualTypeOf<Event>();
});
});

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<ToolActivatedEvent>().toExtend<Event>();
expectTypeOf<ToolCancelEvent>().toExtend<Event>();
expectTypeOf<ToolActivatedEvent["toolName"]>().toEqualTypeOf<string>();
expectTypeOf<ToolCancelEvent["toolName"]>().toEqualTypeOf<string>();
});

it("still accepts event names it does not know about", () => {
const mc = {} as ModelContext;
mc.addEventListener("someotherevent", (ev) => {
expectTypeOf(ev).toEqualTypeOf<Event>();
});
mc.removeEventListener("someotherevent", null);
});

it("reaches the new members through the global document augmentation", () => {
document.modelContext?.addEventListener("toolactivated", (ev) => {
expectTypeOf(ev.toolName).toEqualTypeOf<string>();
});
expectTypeOf(document.modelContext?.ontoolcancel).toEqualTypeOf<
((this: ModelContext, ev: ToolCancelEvent) => unknown) | null | undefined
>();
});
});
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ export type {
ExecuteToolOptions,
McpToolConfigJsonSchema,
McpToolConfigZod,
ModelContextEventMap,
ModelContextGetToolOptions,
RegisteredTool,
RegisterToolOptions,
ToolActivatedEvent,
ToolAnnotations,
ToolCancelEvent,
ToolExecuteCallbackOptions,
ToolExecutionState,
UseMcpToolReturn,
Expand Down
36 changes: 32 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<undefined>;
getTools?(options?: ModelContextGetToolOptions): Promise<RegisteredTool[]>;
Expand All @@ -188,14 +204,26 @@ export interface ModelContext extends EventTarget {
options?: ExecuteToolOptions,
): Promise<string | null>;
ontoolchange: ((this: ModelContext, ev: Event) => unknown) | null;
ontoolactivated: ((this: ModelContext, ev: ToolActivatedEvent) => unknown) | null;
ontoolcancel: ((this: ModelContext, ev: ToolCancelEvent) => unknown) | null;
addEventListener<K extends keyof ModelContextEventMap>(
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<K extends keyof ModelContextEventMap>(
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;
}
Expand Down
8 changes: 8 additions & 0 deletions tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/__tests__/**/*.test-d.ts"],
"exclude": []
}
5 changes: 5 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Loading