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
86 changes: 6 additions & 80 deletions docs/src/api/class-webmcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@
* since: v1.64
* langs: js

[WebMCP] exposes the tools that a frame registers through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) browser API, `navigator.modelContext`. It lists the tools, reports when the set of tools changes, and calls the tools.
[WebMCP] exposes the tools that a frame registers through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) browser API, `navigator.modelContext`. It lists the tools and calls them.

Instances are accessed through [`property: Frame.webmcp`]. [`property: Page.webmcp`] is the instance of the main frame. Call [`method: WebMCP.enable`] before using the other methods.
Instances are accessed through [`property: Frame.webmcp`]. [`property: Page.webmcp`] is the instance of the main frame.

:::note
WebMCP is an experimental browser feature. Chromium enables it with the `--enable-features=WebMCP` launch argument, Firefox with the `dom.modelcontext.enabled` preference. WebKit does not implement it.
WebMCP is an experimental browser feature. Chromium enables it with the `--enable-features=WebMCP` launch argument, Firefox with the `dom.modelcontext.enabled` and `dom.modelcontext.testing.enabled` preferences. WebKit does not implement it.
:::

Tool names, descriptions, input schemas and results are provided by the page, so treat them as untrusted input.

```js
const browser = await chromium.launch({ args: ['--enable-features=WebMCP'] });
const page = await browser.newPage();
await page.webmcp.enable();
await page.goto('https://example.com');

for (const tool of await page.webmcp.tools())
Expand All @@ -24,26 +23,6 @@ for (const tool of await page.webmcp.tools())
const result = await page.webmcp.callTool('add', { a: 2, b: 40 });
```

## event: WebMCP.toolsChanged
* since: v1.64
- argument: <[Array]<[Object]>>
* alias: WebMCPTool
- `name` <[string]> Tool name, unique within the frame.
- `description` <[string]> Tool description.
- `inputSchema` ?<[Serializable]> JSON Schema of the tool input, when the page provides one.
- `annotations` ?<[Object]> Hints the page provides about the tool.
- `readOnly` ?<[boolean]> The tool does not modify any state.
- `untrustedContent` ?<[boolean]> The tool output may contain third-party content.
- `consequential` ?<[boolean]> The tool takes a consequential action, such as placing an order.

Emitted while WebMCP is enabled, whenever the set of tools registered by the frame changes, for example when the page registers or unregisters a tool, or when the frame navigates away. The argument is the new list of tools, the same one [`method: WebMCP.tools`] returns.

```js
page.webmcp.on('toolschanged', tools => {
console.log('tools are now', tools.map(tool => tool.name));
});
```

## async method: WebMCP.callTool
* since: v1.64
- returns: <[Serializable]>
Expand All @@ -70,27 +49,6 @@ Input for the tool, matching its `inputSchema`. Defaults to an empty object.
### option: WebMCP.callTool.timeout = %%-input-timeout-js-%%
* since: v1.64

## async method: WebMCP.disable
* since: v1.64

Stops tracking the tools that the frame registers and stops emitting [`event: WebMCP.toolsChanged`]. Disposing the [Disposable] returned by [`method: WebMCP.enable`] does the same.

## async method: WebMCP.enable
* since: v1.64
- returns: <[Disposable]>

Starts tracking the tools that the frame registers, so that [`method: WebMCP.tools`], [`method: WebMCP.callTool`] and [`event: WebMCP.toolsChanged`] work. Throws if the browser was launched without WebMCP support, see the note above for the launch options that enable it. Returns a [Disposable] that disables the tracking again.

Tracking is per frame. [`property: Page.webmcp`] covers the main frame only, child frames are tracked through their own [`property: Frame.webmcp`].

Chromium reports tool registrations natively. Firefox does not, so Playwright instruments `navigator.modelContext` in the page to observe registrations. Tools registered before the call are picked up as well.

```js
await page.webmcp.enable();
await page.goto('https://example.com');
console.log(await page.webmcp.tools());
```

## async method: WebMCP.tools
* since: v1.64
- returns: <[Array]<[Object]>>
Expand All @@ -103,41 +61,9 @@ console.log(await page.webmcp.tools());
- `untrustedContent` ?<[boolean]> The tool output may contain third-party content.
- `consequential` ?<[boolean]> The tool takes a consequential action, such as placing an order.

Returns the tools currently registered by the frame.

### option: WebMCP.tools.timeout = %%-input-timeout-js-%%
* since: v1.64

## async method: WebMCP.waitForEvent
* since: v1.64
* langs: js
- returns: <[any]>

Waits for the event to fire and passes its value into the predicate function. Returns when the predicate returns a truthy value. Throws if the page is closed before the event is fired. Returns the event data value.
Returns the tools currently registered by the frame. Throws if the browser was launched without WebMCP support, see the note above for the launch options that enable it.

```js
const toolsPromise = page.webmcp.waitForEvent('toolschanged');
await page.getByRole('button', { name: 'Sign in' }).click();
const tools = await toolsPromise;
```
[`property: Page.webmcp`] covers the main frame only, child frames list their tools through their own [`property: Frame.webmcp`].

### param: WebMCP.waitForEvent.event = %%-wait-for-event-event-%%
* since: v1.64

### param: WebMCP.waitForEvent.optionsOrPredicate
* since: v1.64
* langs: js
- `optionsOrPredicate` ?<[function]|[Object]>
- `predicate` <[function]> Receives the event data and resolves to truthy value when the waiting should resolve.
- `timeout` ?<[float]> Maximum time to wait for in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` option in the config, or by using the [`method: BrowserContext.setDefaultTimeout`] or [`method: Page.setDefaultTimeout`] methods.

Either a predicate that receives an event or an options object. Optional.

### option: WebMCP.waitForEvent.predicate = %%-wait-for-event-predicate-%%
* since: v1.64

### option: WebMCP.waitForEvent.timeout = %%-wait-for-event-timeout-%%
* since: v1.64

### option: WebMCP.waitForEvent.signal = %%-wait-for-event-signal-%%
### option: WebMCP.tools.timeout = %%-input-timeout-js-%%
* since: v1.64
136 changes: 37 additions & 99 deletions packages/injected/src/webMCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,11 @@ export type WebMCPToolDescription = {
};
};

export type WebMCPScriptOptions = {
property: string;
bindingName: string;
bindingsControllerProperty: string;
};

type RegisteredTool = {
name: string;
description?: string;
inputSchema?: unknown;
annotations?: Record<string, boolean | undefined>;
execute?: (input: unknown) => unknown;
window?: Window;
};

Expand All @@ -46,131 +39,80 @@ type ModelContext = {
invokeTool?: (name: string, input: unknown) => Promise<unknown>;
};

type BindingsController = {
callBinding(name: string, ...args: unknown[]): Promise<unknown>;
};

type GlobalThis = typeof globalThis;

export class WebMCPScript {
private _global: GlobalThis;
private _options: WebMCPScriptOptions;
private _modelContext: ModelContext | undefined;
private _registry = new Map<string, RegisteredTool>();

static install(global: GlobalThis, options: WebMCPScriptOptions): Promise<WebMCPToolDescription[]> {
const existing = (global as any)[options.property] as WebMCPScript | undefined;
if (existing)
return Promise.resolve(existing._describe());
const script = new WebMCPScript(global, options);
Object.defineProperty(global, options.property, { value: script, configurable: true });
return script._collectRegisteredTools();
}

constructor(global: GlobalThis, options: WebMCPScriptOptions) {
constructor(global: GlobalThis) {
this._global = global;
this._options = options;
// Chromium exposes the entry point on `document`, Firefox on `navigator`.
this._modelContext = (global.document as any)?.modelContext ?? (global.navigator as any)?.modelContext;
if (!this._modelContext)
return;
this._wrap('registerTool', (tool: RegisteredTool) => this._registry.set(tool.name, tool));
this._wrap('unregisterTool', (name: string) => this._registry.delete(name));
this._wrap('provideContext', (params?: { tools?: RegisteredTool[] }) => {
this._registry.clear();
for (const tool of params?.tools ?? [])
this._registry.set(tool.name, tool);
});
this._wrap('clearContext', () => this._registry.clear());
}

async callTool(name: string, inputJson: string): Promise<string> {
async tools(): Promise<WebMCPToolDescription[]> {
return (await this._ownTools()).map(tool => this._describe(tool));
}

async callTool(name: string, input: object): Promise<unknown> {
const modelContext = this._modelContext;
if (!modelContext)
throw new Error('WebMCP is not available on this page');
const input = JSON.parse(inputJson);
if (modelContext.invokeTool)
return this._stringify(await modelContext.invokeTool(name, input));
if (modelContext.executeTool && modelContext.getTools) {
const tool = (await modelContext.getTools()).find(tool => this._isOwnTool(tool) && tool.name === name);
if (!tool)
throw new Error(`WebMCP tool "${name}" is not registered in this frame`);
const result = await this._executeTool(modelContext, tool, input, inputJson);
return typeof result === 'string' ? result : this._stringify(result);
}
const tool = this._registry.get(name);
if (!tool?.execute)
return await modelContext.invokeTool(name, input);
const tool = (await this._ownTools()).find(tool => tool.name === name);
if (!tool || !modelContext.executeTool)
throw new Error(`WebMCP tool "${name}" is not registered in this frame`);
return this._stringify(await tool.execute(input));
return this._parseResult(await this._executeTool(modelContext, tool, input));
}

private async _executeTool(modelContext: ModelContext, tool: RegisteredTool, input: object, inputJson: string): Promise<unknown> {
private async _executeTool(modelContext: ModelContext, tool: RegisteredTool, input: object): Promise<unknown> {
try {
return await modelContext.executeTool!(tool, input);
} catch (e) {
// Chromium before 155 takes the input as a JSON string.
if (!String((e as Error)?.message).includes('Failed to parse input arguments'))
throw e;
return await modelContext.executeTool!(tool, inputJson);
return await modelContext.executeTool!(tool, JSON.stringify(input));
}
}

private async _collectRegisteredTools(): Promise<WebMCPToolDescription[]> {
const tools = await this._modelContext?.getTools?.() ?? [];
for (const tool of tools) {
if (this._isOwnTool(tool))
this._registry.set(tool.name, tool);
private _parseResult(result: unknown): unknown {
// Chromium hands the result back as a string.
if (typeof result !== 'string')
return result;
if (result === 'undefined')
return undefined;
try {
return JSON.parse(result);
} catch {
return result;
}
return this._describe();
}

private _isOwnTool(tool: RegisteredTool): boolean {
private async _ownTools(): Promise<RegisteredTool[]> {
const tools = await this._modelContext?.getTools?.() ?? [];
// Chromium's getTools() aggregates same-origin descendant frames, Firefox's does not.
return !('window' in tool) || tool.window === this._global.window;
return tools.filter(tool => !('window' in tool) || tool.window === this._global.window);
}

private _wrap(method: string, update: (...args: any[]) => void) {
const prototype = Object.getPrototypeOf(this._modelContext);
const original = prototype[method];
if (typeof original !== 'function')
return;
const script = this;
prototype[method] = function(this: unknown, ...args: unknown[]) {
const result = original.apply(this, args);
update(...args);
script._report();
return result;
private _describe(tool: RegisteredTool): WebMCPToolDescription {
const annotations = tool.annotations;
return {
name: tool.name,
description: tool.description ?? '',
inputSchema: this._parseInputSchema(tool.inputSchema),
annotations: annotations ? {
readOnly: annotations.readOnlyHint,
untrustedContent: annotations.untrustedContentHint,
consequential: annotations.consequentialHint,
} : undefined,
};
}

private _report() {
const controller = (this._global as any)[this._options.bindingsControllerProperty] as BindingsController | undefined;
// Calling a disposed binding throws, the page must not notice.
try {
controller?.callBinding(this._options.bindingName, this._describe()).catch(() => {});
} catch {
}
}

private _describe(): WebMCPToolDescription[] {
return [...this._registry.values()].map(tool => {
const annotations = tool.annotations;
return {
name: tool.name,
description: tool.description ?? '',
inputSchema: this._parseInputSchema(tool.inputSchema),
// The JS surface uses the `*Hint` names, the CDP WebMCP domain uses the short ones.
annotations: annotations ? {
readOnly: annotations.readOnlyHint ?? annotations.readOnly,
untrustedContent: annotations.untrustedContentHint ?? annotations.untrustedContent,
consequential: annotations.consequentialHint ?? annotations.consequential,
} : undefined,
};
});
}

private _parseInputSchema(inputSchema: unknown): unknown {
// Chromium hands the schema back as a JSON string, Firefox as an object.
// Chromium before 155 hands the schema back as a JSON string.
if (typeof inputSchema !== 'string')
return inputSchema;
try {
Expand All @@ -179,8 +121,4 @@ export class WebMCPScript {
return undefined;
}
}

private _stringify(result: unknown): string {
return result === undefined ? 'null' : JSON.stringify(result);
}
}
2 changes: 0 additions & 2 deletions packages/isomorphic/protocolMetainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,6 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['Frame.waitForFunction', { title: 'Wait for function', subtitle: '{selector}', snapshot: true, pause: true, }],
['Frame.waitForSelector', { title: 'Wait for selector', subtitle: '{selector}', renderParams: ['state'], snapshot: true, }],
['Frame.expect', { title: 'Expect "{expression}"', subtitle: '{selector}', snapshot: true, pause: true, }],
['Frame.webmcpEnable', { title: 'Enable WebMCP', group: 'configuration', }],
['Frame.webmcpDisable', { title: 'Disable WebMCP', group: 'configuration', }],
['Frame.webmcpTools', { title: 'List WebMCP tools', group: 'getter', }],
['Frame.webmcpCallTool', { title: 'Call WebMCP tool', }],
['JSHandle.dispose', { internal: true, }],
Expand Down
Loading
Loading