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
7 changes: 7 additions & 0 deletions docs/src/api/class-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -2433,3 +2433,10 @@ await frame.WaitForURLAsync("**/target.html");

### option: Frame.waitForURL.waitUntil = %%-navigation-wait-until-%%
* since: v1.11

## property: Frame.webmcp
* since: v1.64
* langs: js
- type: <[WebMCP]>

Tools that the frame registers through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) browser API. See [WebMCP] for details.
14 changes: 14 additions & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -5925,6 +5925,20 @@ Receives the [Worker] object and resolves to truthy value when the waiting shoul
### param: Page.waitForWorker.callback = %%-java-wait-for-event-callback-%%
* since: v1.9

## property: Page.webmcp
* since: v1.64
* langs: js
- type: <[WebMCP]>

Tools that the main frame registers through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) browser API. Shortcut for [`property: Frame.webmcp`] of [`method: Page.mainFrame`], see [WebMCP] for details.

**Usage**

```js
const tools = await page.webmcp.tools();
const result = await page.webmcp.callTool('add', { a: 2, b: 40 });
```

## method: Page.workers
* since: v1.8
- returns: <[Array]<[Worker]>>
Expand Down
143 changes: 143 additions & 0 deletions docs/src/api/class-webmcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# class: WebMCP
* 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.

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.

:::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.
:::

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())
console.log(tool.name, tool.description);

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]>

Calls a tool registered by the frame and returns its result. The result is whatever the tool's `execute` function resolved to, typically an object with a `content` array. A result with `isError: true` is returned as is. The method throws when the tool is not registered or its `execute` function throws.

```js
const result = await page.webmcp.callTool('add', { a: 2, b: 40 });
console.log(result.content[0].text); // "42"
```

### param: WebMCP.callTool.name
* since: v1.64
- `name` <[string]>

Name of the tool, as reported by [`method: WebMCP.tools`].

### param: WebMCP.callTool.input
* since: v1.64
- `input` ?<[Serializable]>

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]>>
* 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.

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.

```js
const toolsPromise = page.webmcp.waitForEvent('toolschanged');
await page.getByRole('button', { name: 'Sign in' }).click();
const tools = await toolsPromise;
```

### 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-%%
* since: v1.64
186 changes: 186 additions & 0 deletions packages/injected/src/webMCP.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export type WebMCPToolDescription = {
name: string;
description: string;
inputSchema?: unknown;
annotations?: {
readOnly?: boolean;
untrustedContent?: boolean;
consequential?: boolean;
};
};

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;
};

type ModelContext = {
getTools?: () => Promise<RegisteredTool[]>;
executeTool?: (tool: RegisteredTool, input: object | string) => Promise<unknown>;
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) {
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> {
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)
throw new Error(`WebMCP tool "${name}" is not registered in this frame`);
return this._stringify(await tool.execute(input));
}

private async _executeTool(modelContext: ModelContext, tool: RegisteredTool, input: object, inputJson: string): 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);
}
}

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);
}
return this._describe();
}

private _isOwnTool(tool: RegisteredTool): boolean {
// Chromium's getTools() aggregates same-origin descendant frames, Firefox's does not.
return !('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 _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.
if (typeof inputSchema !== 'string')
return inputSchema;
try {
return JSON.parse(inputSchema);
} catch {
return undefined;
}
}

private _stringify(result: unknown): string {
return result === undefined ? 'null' : JSON.stringify(result);
}
}
4 changes: 4 additions & 0 deletions packages/isomorphic/protocolMetainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ 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, }],
['ElementHandle.dispose', { internal: true, }],
['JSHandle.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }],
Expand Down
Loading
Loading