From f64532019b86abe8d590aaac92b24e12a020c2bf Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 28 Sep 2026 19:56:20 -0700 Subject: [PATCH] feat(webmcp): add page.webmcp API --- docs/src/api/class-frame.md | 7 + docs/src/api/class-page.md | 14 + docs/src/api/class-webmcp.md | 143 +++++ packages/injected/src/webMCP.ts | 186 ++++++ packages/isomorphic/protocolMetainfo.ts | 4 + packages/playwright-client/types/types.d.ts | 544 ++++++++++++++++++ .../playwright-core/src/client/channels.d.ts | 48 ++ packages/playwright-core/src/client/events.ts | 4 + packages/playwright-core/src/client/frame.ts | 3 + packages/playwright-core/src/client/page.ts | 4 +- packages/playwright-core/src/client/webmcp.ts | 73 +++ .../playwright-core/src/server/channels.d.ts | 48 ++ .../src/server/chromium/crPage.ts | 77 +++ .../src/server/dispatchers/frameDispatcher.ts | 18 + packages/playwright-core/src/server/frames.ts | 6 + packages/playwright-core/src/server/page.ts | 5 + packages/playwright-core/src/server/webmcp.ts | 276 +++++++++ .../playwright-core/src/tools/backend/tab.ts | 23 + .../src/tools/backend/webmcp.ts | 151 +---- packages/playwright-core/types/types.d.ts | 544 ++++++++++++++++++ packages/protocol/spec/frame.yml | 52 ++ packages/protocol/spec/page.yml | 1 + packages/protocol/src/validator.ts | 36 ++ tests/library/webmcp-disabled.spec.ts | 26 + tests/library/webmcp.spec.ts | 320 +++++++++++ utils/generate_injected.js | 5 + 26 files changed, 2482 insertions(+), 136 deletions(-) create mode 100644 docs/src/api/class-webmcp.md create mode 100644 packages/injected/src/webMCP.ts create mode 100644 packages/playwright-core/src/client/webmcp.ts create mode 100644 packages/playwright-core/src/server/webmcp.ts create mode 100644 tests/library/webmcp-disabled.spec.ts create mode 100644 tests/library/webmcp.spec.ts diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 05e7bf157f84f..de7735b599bdc 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -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. diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index e73477edba32f..6d8a65d002613 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -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]>> diff --git a/docs/src/api/class-webmcp.md b/docs/src/api/class-webmcp.md new file mode 100644 index 0000000000000..d927ce70beb32 --- /dev/null +++ b/docs/src/api/class-webmcp.md @@ -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 diff --git a/packages/injected/src/webMCP.ts b/packages/injected/src/webMCP.ts new file mode 100644 index 0000000000000..c76394911904e --- /dev/null +++ b/packages/injected/src/webMCP.ts @@ -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; + execute?: (input: unknown) => unknown; + window?: Window; +}; + +type ModelContext = { + getTools?: () => Promise; + executeTool?: (tool: RegisteredTool, input: object | string) => Promise; + invokeTool?: (name: string, input: unknown) => Promise; +}; + +type BindingsController = { + callBinding(name: string, ...args: unknown[]): Promise; +}; + +type GlobalThis = typeof globalThis; + +export class WebMCPScript { + private _global: GlobalThis; + private _options: WebMCPScriptOptions; + private _modelContext: ModelContext | undefined; + private _registry = new Map(); + + static install(global: GlobalThis, options: WebMCPScriptOptions): Promise { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 102877079ee7d..684aee3296cda 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -172,6 +172,10 @@ export const methodMetainfo = new Map([ ['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, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index cab3c0528a613..e7b77e4586529 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -5885,6 +5885,22 @@ export interface Page { touchscreen: Touchscreen; + /** + * Tools that the main frame registers through the experimental [WebMCP](https://playwright.dev/docs/api/class-webmcp) + * browser API. Shortcut for [frame.webmcp](https://playwright.dev/docs/api/class-frame#frame-webmcp) of + * [page.mainFrame()](https://playwright.dev/docs/api/class-page#page-main-frame), see + * [WebMCP](https://playwright.dev/docs/api/class-webmcp) for details. + * + * **Usage** + * + * ```js + * const tools = await page.webmcp.tools(); + * const result = await page.webmcp.callTool('add', { a: 2, b: 40 }); + * ``` + * + */ + webmcp: WebMCP; + [Symbol.asyncDispose](): Promise; } @@ -9139,6 +9155,12 @@ export interface Frame { */ waitUntil?: "load"|"domcontentloaded"|"networkidle"|"commit"; }): Promise; + + /** + * Tools that the frame registers through the experimental [WebMCP](https://playwright.dev/docs/api/class-webmcp) + * browser API. See [WebMCP](https://playwright.dev/docs/api/class-webmcp) for details. + */ + webmcp: WebMCP; } /** @@ -23668,6 +23690,528 @@ export interface WebError { page(): null|Page; } +/** + * [WebMCP](https://playwright.dev/docs/api/class-webmcp) exposes the tools that a frame registers through the + * experimental [WebMCP](https://playwright.dev/docs/api/class-webmcp) browser API, `navigator.modelContext`. It lists + * the tools, reports when the set of tools changes, and calls the tools. + * + * Instances are accessed through [frame.webmcp](https://playwright.dev/docs/api/class-frame#frame-webmcp). + * [page.webmcp](https://playwright.dev/docs/api/class-page#page-webmcp) is the instance of the main frame. Call + * [webMCP.enable()](https://playwright.dev/docs/api/class-webmcp#web-mcp-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 }); + * ``` + * + */ +export interface WebMCP { + /** + * 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 [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools) returns. + * + * ```js + * page.webmcp.on('toolschanged', tools => { + * console.log('tools are now', tools.map(tool => tool.name)); + * }); + * ``` + * + */ + on(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * 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 [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools) returns. + * + * ```js + * page.webmcp.on('toolschanged', tools => { + * console.log('tools are now', tools.map(tool => tool.name)); + * }); + * ``` + * + */ + addListener(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * 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 [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools) returns. + * + * ```js + * page.webmcp.on('toolschanged', tools => { + * console.log('tools are now', tools.map(tool => tool.name)); + * }); + * ``` + * + */ + prependListener(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * 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 name Name of the tool, as reported by + * [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools). + * @param input Input for the tool, matching its `inputSchema`. Defaults to an empty object. + * @param options + */ + callTool(name: string, input?: Serializable, options?: { + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + + /** + * Stops tracking the tools that the frame registers and stops emitting + * [webMCP.on('toolschanged')](https://playwright.dev/docs/api/class-webmcp#web-mcp-event-tools-changed). Disposing + * the [Disposable](https://playwright.dev/docs/api/class-disposable) returned by + * [webMCP.enable()](https://playwright.dev/docs/api/class-webmcp#web-mcp-enable) does the same. + */ + disable(): Promise; + + /** + * Starts tracking the tools that the frame registers, so that + * [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools), + * [webMCP.callTool(name[, input, options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-call-tool) and + * [webMCP.on('toolschanged')](https://playwright.dev/docs/api/class-webmcp#web-mcp-event-tools-changed) work. Throws + * if the browser was launched without WebMCP support, see the note above for the launch options that enable it. + * Returns a [Disposable](https://playwright.dev/docs/api/class-disposable) that disables the tracking again. + * + * Tracking is per frame. [page.webmcp](https://playwright.dev/docs/api/class-page#page-webmcp) covers the main frame + * only, child frames are tracked through their own + * [frame.webmcp](https://playwright.dev/docs/api/class-frame#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()); + * ``` + * + */ + enable(): Promise; + + /** + * Returns the tools currently registered by the frame. + * @param options + */ + tools(options?: { + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise>; + + /** + * 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 [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools) returns. + * + * ```js + * page.webmcp.on('toolschanged', tools => { + * console.log('tools are now', tools.map(tool => tool.name)); + * }); + * ``` + * + */ + waitForEvent(event: 'toolschanged', optionsOrPredicate?: { predicate?: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => boolean | Promise)): Promise>; + +} + /** * The [WebSocket](https://playwright.dev/docs/api/class-websocket) class represents WebSocket connections within a * page. It provides the ability to inspect and manipulate the data being transmitted and received. diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 531bd08320974..91c4df4d4fec2 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -2247,6 +2247,7 @@ export type FrameInitializer = { export interface FrameEventTarget { on(event: 'loadstate', callback: (params: FrameLoadstateEvent) => void): this; on(event: 'navigated', callback: (params: FrameNavigatedEvent) => void): this; + on(event: 'webmcpToolsChanged', callback: (params: FrameWebmcpToolsChangedEvent) => void): this; } export interface FrameChannel extends FrameEventTarget, Channel { _type_Frame: boolean; @@ -2300,6 +2301,10 @@ export interface FrameChannel extends FrameEventTarget, Channel { waitForFunction(params: FrameWaitForFunctionParams, options: TimeoutOptions): Promise; waitForSelector(params: FrameWaitForSelectorParams, options: TimeoutOptions): Promise; expect(params: FrameExpectParams, options: TimeoutOptions): Promise; + webmcpEnable(params: FrameWebmcpEnableParams, options: TimeoutOptions): Promise; + webmcpDisable(params: FrameWebmcpDisableParams, options: TimeoutOptions): Promise; + webmcpTools(params: FrameWebmcpToolsParams, options: TimeoutOptions): Promise; + webmcpCallTool(params: FrameWebmcpCallToolParams, options: TimeoutOptions): Promise; } export type FrameLoadstateEvent = { add?: LifecycleEvent, @@ -2313,6 +2318,18 @@ export type FrameNavigatedEvent = { }, error?: string, }; +export type FrameWebmcpToolsChangedEvent = { + tools: { + name: string, + description: string, + inputSchema?: any, + annotations?: { + readOnly?: boolean, + untrustedContent?: boolean, + consequential?: boolean, + }, + }[], +}; export type FrameEvalOnSelectorParams = { selector: string, strict?: boolean, @@ -2980,10 +2997,41 @@ export type FrameExpectErrorDetails = { timedOut?: boolean, customErrorMessage?: string, }; +export type FrameWebmcpEnableParams = {}; +export type FrameWebmcpEnableOptions = {}; +export type FrameWebmcpEnableResult = void; +export type FrameWebmcpDisableParams = {}; +export type FrameWebmcpDisableOptions = {}; +export type FrameWebmcpDisableResult = void; +export type FrameWebmcpToolsParams = {}; +export type FrameWebmcpToolsOptions = {}; +export type FrameWebmcpToolsResult = { + tools: { + name: string, + description: string, + inputSchema?: any, + annotations?: { + readOnly?: boolean, + untrustedContent?: boolean, + consequential?: boolean, + }, + }[], +}; +export type FrameWebmcpCallToolParams = { + name: string, + input?: any, +}; +export type FrameWebmcpCallToolOptions = { + input?: any, +}; +export type FrameWebmcpCallToolResult = { + result?: any, +}; export interface FrameEvents { 'loadstate': FrameLoadstateEvent; 'navigated': FrameNavigatedEvent; + 'webmcpToolsChanged': FrameWebmcpToolsChangedEvent; } // ----------- JSHandle ----------- diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index 0233d000fe9cd..2956cccacd8d4 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -92,6 +92,10 @@ export const Events = { Worker: 'worker', }, + WebMCP: { + ToolsChanged: 'toolschanged', + }, + WebSocket: { Close: 'close', Error: 'socketerror', diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 9b2b238d004e4..accf6079cf8ec 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -33,6 +33,7 @@ import { FrameLocator, Locator, testIdAttributeName } from './locator'; import * as network from './network'; import { kLifecycleEvents } from './types'; import { Waiter } from './waiter'; +import { WebMCP } from './webmcp'; import { TimeoutSettings, kNoTimeout } from './timeoutSettings'; import type { EvaluateOptions, EvaluateHandleOptions, WorldOptions } from './jsHandle'; @@ -61,6 +62,7 @@ export class Frame extends ChannelOwner implements api.Fr _detached = false; _childFrames = new Set(); _page: Page | undefined; + readonly webmcp: WebMCP; static from(frame: channels.FrameChannel): Frame { return (frame as any)._object; @@ -74,6 +76,7 @@ export class Frame extends ChannelOwner implements api.Fr super(parent, type, guid, initializer); this._eventEmitter = new EventEmitter(); this._eventEmitter.setMaxListeners(0); + this.webmcp = new WebMCP(this); this._parentFrame = Frame.fromNullable(initializer.parentFrame); if (this._parentFrame) this._parentFrame._childFrames.add(this); diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 7107e15058ab3..7a3d6fad752bd 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -63,6 +63,7 @@ import type { AriaSnapshotJSON } from '@isomorphic/ariaSnapshot'; import type { ByRoleOptions } from '@isomorphic/locatorUtils'; import type { URLMatch } from '@isomorphic/urlMatch'; import type * as channels from './channels'; +import type { WebMCP } from './webmcp'; type PDFOptions = Omit & { width?: string | number, @@ -109,7 +110,7 @@ export class Page extends ChannelOwner implements api.Page readonly screencast: Screencast; readonly localStorage: WebStorage; readonly sessionStorage: WebStorage; - + readonly webmcp: WebMCP; readonly _bindings = new Map any>(); readonly _timeoutSettings: TimeoutSettings; @@ -146,6 +147,7 @@ export class Page extends ChannelOwner implements api.Page this._mainFrame = Frame.from(initializer.mainFrame); this._mainFrame._page = this; + this.webmcp = this._mainFrame.webmcp; this._frames.add(this._mainFrame); this._viewportSize = initializer.viewportSize; this._closed = initializer.isClosed; diff --git a/packages/playwright-core/src/client/webmcp.ts b/packages/playwright-core/src/client/webmcp.ts new file mode 100644 index 0000000000000..f4f89c6965143 --- /dev/null +++ b/packages/playwright-core/src/client/webmcp.ts @@ -0,0 +1,73 @@ +/** + * 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. + */ + +import { DisposableStub } from './disposable'; +import { EventEmitter } from './eventEmitter'; +import { Events } from './events'; +import { kNoTimeout } from './timeoutSettings'; +import { Waiter } from './waiter'; + +import type { Frame } from './frame'; +import type * as api from '../../types/types'; +import type * as channels from './channels'; +import type { WaitForEventOptions } from './types'; + +export type WebMCPTool = channels.FrameWebmcpToolsResult['tools'][number]; + +export class WebMCP extends EventEmitter implements api.WebMCP { + private _frame: Frame; + + constructor(frame: Frame) { + super(); + this.setMaxListeners(0); + this._frame = frame; + this._frame._channel.on('webmcpToolsChanged', ({ tools }) => this.emit(Events.WebMCP.ToolsChanged, tools)); + } + + async enable(): Promise { + await this._frame._channel.webmcpEnable({}, kNoTimeout); + return new DisposableStub(() => this.disable()); + } + + async disable(): Promise { + await this._frame._channel.webmcpDisable({}, kNoTimeout); + } + + async tools(options: { timeout?: number } = {}): Promise { + const { tools } = await this._frame._channel.webmcpTools({}, this._frame._timeout(options)); + return tools; + } + + async callTool(name: string, input?: any, options: { timeout?: number } = {}): Promise { + const { result } = await this._frame._channel.webmcpCallTool({ name, input }, this._frame._timeout(options)); + return result; + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._frame._wrapApiCall(async () => { + const timeoutOptions = this._frame._timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this._frame, event); + waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded while waiting for event "${event}"`); + const page = this._frame._page; + if (page) + waiter.rejectOnEvent(page, Events.Page.Close, () => page._closeErrorWithReason()); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } +} diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index ed5766412d229..f9ff479a4fd91 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -2248,6 +2248,7 @@ export type FrameInitializer = { export interface FrameEventTarget { _dispatchEvent(event: 'loadstate', params?: FrameLoadstateEvent): void; _dispatchEvent(event: 'navigated', params?: FrameNavigatedEvent): void; + _dispatchEvent(event: 'webmcpToolsChanged', params?: FrameWebmcpToolsChangedEvent): void; } export interface FrameChannel extends FrameEventTarget, Channel { _type_Frame: boolean; @@ -2301,6 +2302,10 @@ export interface FrameChannel extends FrameEventTarget, Channel { waitForFunction(params: FrameWaitForFunctionParams, progress: Progress): Promise; waitForSelector(params: FrameWaitForSelectorParams, progress: Progress): Promise; expect(params: FrameExpectParams, progress: Progress): Promise; + webmcpEnable(params: FrameWebmcpEnableParams, progress: Progress): Promise; + webmcpDisable(params: FrameWebmcpDisableParams, progress: Progress): Promise; + webmcpTools(params: FrameWebmcpToolsParams, progress: Progress): Promise; + webmcpCallTool(params: FrameWebmcpCallToolParams, progress: Progress): Promise; } export type FrameLoadstateEvent = { add?: LifecycleEvent, @@ -2314,6 +2319,18 @@ export type FrameNavigatedEvent = { }, error?: string, }; +export type FrameWebmcpToolsChangedEvent = { + tools: { + name: string, + description: string, + inputSchema?: any, + annotations?: { + readOnly?: boolean, + untrustedContent?: boolean, + consequential?: boolean, + }, + }[], +}; export type FrameEvalOnSelectorParams = { selector: string, strict?: boolean, @@ -2981,10 +2998,41 @@ export type FrameExpectErrorDetails = { timedOut?: boolean, customErrorMessage?: string, }; +export type FrameWebmcpEnableParams = {}; +export type FrameWebmcpEnableOptions = {}; +export type FrameWebmcpEnableResult = void; +export type FrameWebmcpDisableParams = {}; +export type FrameWebmcpDisableOptions = {}; +export type FrameWebmcpDisableResult = void; +export type FrameWebmcpToolsParams = {}; +export type FrameWebmcpToolsOptions = {}; +export type FrameWebmcpToolsResult = { + tools: { + name: string, + description: string, + inputSchema?: any, + annotations?: { + readOnly?: boolean, + untrustedContent?: boolean, + consequential?: boolean, + }, + }[], +}; +export type FrameWebmcpCallToolParams = { + name: string, + input?: any, +}; +export type FrameWebmcpCallToolOptions = { + input?: any, +}; +export type FrameWebmcpCallToolResult = { + result?: any, +}; export interface FrameEvents { 'loadstate': FrameLoadstateEvent; 'navigated': FrameNavigatedEvent; + 'webmcpToolsChanged': FrameWebmcpToolsChangedEvent; } // ----------- JSHandle ----------- diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index e13652b98b7e1..e7f81f86daae5 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -18,6 +18,7 @@ import { assert } from '@isomorphic/assert'; import { rewriteErrorMessage } from '@utils/stackTrace'; import { eventsHelper } from '@utils/eventsHelper'; +import { ManualPromise } from '@isomorphic/manualPromise'; import * as dialog from '../dialog'; import * as dom from '../dom'; import * as frames from '../frames'; @@ -45,6 +46,7 @@ import type { InitScript, PageDelegate } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; import type * as channels from '../channels'; +import type { RawWebMCPTool } from '../webmcp'; export type WindowBounds = { top?: number, left?: number, width?: number, height?: number }; @@ -86,6 +88,9 @@ export class CRPage implements PageDelegate { // of their Page.windowOpen events is not guaranteed to match the order // of new popup targets. readonly _nextWindowOpenPopupFeatures: string[][] = []; + // Resolves to whether the browser implements the WebMCP domain. + _webmcpSupported: Promise = Promise.resolve(false); + private readonly _webmcpInvocations = new Map>(); static mainFrameSession(page: Page): FrameSession { const crPage = page.delegate as CRPage; @@ -161,6 +166,34 @@ export class CRPage implements PageDelegate { this._mainFrameSession._willBeginDownload(); } + enableWebMCP(): Promise { + return this._webmcpSupported; + } + + async callWebMCPTool(progress: Progress, frame: frames.Frame, name: string, input: unknown): Promise { + const client = this._sessionForFrame(frame)._client; + const { invocationId } = await progress.race(client.send('WebMCP.invokeTool', { frameId: frame._id, toolName: name, input: input as Protocol.WebMCP.invokeToolParameters['input'] })); + // The invocation id arrives before any of the invocation events. + const responded = new ManualPromise(); + this._webmcpInvocations.set(invocationId, responded); + try { + const response = await progress.race(responded); + if (response.exception?.objectId) + releaseObject(client, response.exception.objectId); + if (response.status === 'Completed') + return response.output; + throw new Error(response.errorText || response.exception?.description || `WebMCP tool "${name}" ${response.status === 'Canceled' ? 'was canceled' : 'failed'}`); + } finally { + this._webmcpInvocations.delete(invocationId); + if (!responded.isDone()) + client._sendMayFail('WebMCP.cancelInvocation', { invocationId }); + } + } + + _onWebMCPToolResponded(event: Protocol.WebMCP.toolRespondedPayload) { + this._webmcpInvocations.get(event.invocationId)?.resolve(event); + } + didClose() { for (const session of this._sessions.values()) session.dispose(); @@ -456,6 +489,9 @@ class FrameSession { eventsHelper.addEventListener(this._client, 'Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)), eventsHelper.addEventListener(this._client, 'Runtime.executionContextDestroyed', event => this._onExecutionContextDestroyed(event.executionContextId)), eventsHelper.addEventListener(this._client, 'Runtime.executionContextsCleared', event => this._onExecutionContextsCleared()), + eventsHelper.addEventListener(this._client, 'WebMCP.toolsAdded', event => this._onWebMCPToolsAdded(event)), + eventsHelper.addEventListener(this._client, 'WebMCP.toolsRemoved', event => this._onWebMCPToolsRemoved(event)), + eventsHelper.addEventListener(this._client, 'WebMCP.toolResponded', event => this._crPage._onWebMCPToolResponded(event)), ]); } @@ -497,6 +533,12 @@ class FrameSession { // so we have to buffer events instead. this._bufferedAttachedToTargetEvents = []; + // Enabling only reports the tools of the main frame, so listen from the start to catch the + // ones that child frames register. Older browsers do not have the domain. + const webmcpEnabled = this._client._sendMayFail('WebMCP.enable').then(result => !!result); + if (this._isMainFrame()) + this._crPage._webmcpSupported = webmcpEnabled; + const promises: Promise[] = [ this._client.send('Page.enable'), this._client.send('Page.getFrameTree').then(({ frameTree }) => { @@ -538,6 +580,7 @@ class FrameSession { this._client.send('Log.enable', {}), lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), this._client.send('Runtime.enable', {}), + webmcpEnabled, this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: '', worldName: this._crPage.utilityWorldName, @@ -881,6 +924,40 @@ class FrameSession { } } + private _onWebMCPToolsAdded(event: Protocol.WebMCP.toolsAddedPayload) { + const toolsByFrame = new Map(); + for (const tool of event.tools) { + const frame = this._page.frameManager.frame(tool.frameId); + if (!frame) + continue; + let tools = toolsByFrame.get(frame); + if (!tools) { + tools = []; + toolsByFrame.set(frame, tools); + } + tools.push({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations }); + } + for (const [frame, tools] of toolsByFrame) + frame.webmcp.toolsAdded(tools); + } + + private _onWebMCPToolsRemoved(event: Protocol.WebMCP.toolsRemovedPayload) { + const namesByFrame = new Map(); + for (const tool of event.tools) { + const frame = this._page.frameManager.frame(tool.frameId); + if (!frame) + continue; + let names = namesByFrame.get(frame); + if (!names) { + names = []; + namesByFrame.set(frame, names); + } + names.push(tool.name); + } + for (const [frame, names] of namesByFrame) + frame.webmcp.toolsRemoved(names); + } + _onDialog(event: Protocol.Page.javascriptDialogOpeningPayload) { if (!this._page.frameManager.frame(this._targetId)) return; // Our frame/subtree may be gone already. diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index b6b9f7f0dbeab..f2773bc7c442f 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -25,6 +25,7 @@ import type { Progress } from '../progress'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { PageDispatcher } from './pageDispatcher'; import type { NavigationEvent } from '../frames'; +import type { WebMCPToolInfo } from '../webmcp'; import type * as channels from '../channels'; export class FrameDispatcher extends Dispatcher implements channels.FrameChannel { @@ -57,6 +58,7 @@ export class FrameDispatcher extends Dispatcher this._dispatchEvent('webmcpToolsChanged', { tools })); this.addObjectListener(Frame.Events.AddLifecycle, lifecycleEvent => { this._dispatchEvent('loadstate', { add: lifecycleEvent }); }); @@ -254,6 +256,22 @@ export class FrameDispatcher extends Dispatcher { + await this._frame.webmcp.enable(progress); + } + + async webmcpDisable(params: channels.FrameWebmcpDisableParams, progress: Progress): Promise { + await this._frame.webmcp.disable(progress); + } + + async webmcpTools(params: channels.FrameWebmcpToolsParams, progress: Progress): Promise { + return { tools: await this._frame.webmcp.tools(progress) }; + } + + async webmcpCallTool(params: channels.FrameWebmcpCallToolParams, progress: Progress): Promise { + return { result: await this._frame.webmcp.callTool(progress, params.name, params.input) }; + } + async waitForTimeout(params: channels.FrameWaitForTimeoutParams, progress: Progress): Promise { return await this._frame.waitForTimeout(progress, params.waitTimeout); } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index ea161bf3c0017..3bd1af6843df5 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -39,6 +39,7 @@ import { Page, ariaSnapshotJSONForFrame } from './page'; import { isAbortError, nullProgress, ProgressController, raceUncancellableOperationWithCleanup } from './progress'; import * as types from './types'; import { isSessionClosedError } from './protocolError'; +import { WebMCP } from './webmcp'; import type { ConsoleMessage } from './console'; import type { ElementStateWithoutStable, FrameExpectParams, InjectedScript } from '@injected/injectedScript'; @@ -47,6 +48,7 @@ import type { ScreenshotOptions } from './screenshotter'; import type { RegisteredListener } from '@utils/eventsHelper'; import type * as channels from './channels'; import type { AriaSnapshotJSON } from '@isomorphic/ariaSnapshot'; +import type { WebMCPToolInfo } from './webmcp'; type ContextData = { contextPromise: ManualPromise; @@ -502,12 +504,14 @@ const FrameEvent = { InternalNavigation: 'internalnavigation', AddLifecycle: 'addlifecycle', RemoveLifecycle: 'removelifecycle', + WebMCPToolsChanged: 'webmcptoolschanged', } as const; export type FrameEventMap = { [FrameEvent.InternalNavigation]: [event: NavigationEvent]; [FrameEvent.AddLifecycle]: [event: types.LifecycleEvent]; [FrameEvent.RemoveLifecycle]: [event: types.LifecycleEvent]; + [FrameEvent.WebMCPToolsChanged]: [tools: WebMCPToolInfo[]]; }; export class Frame extends SdkObject { @@ -515,6 +519,7 @@ export class Frame extends SdkObject { _id: string; seq: number; + readonly webmcp: WebMCP; _firedLifecycleEvents = new Set(); private _firedNetworkIdleSelf = false; _currentDocument: DocumentInfo; @@ -536,6 +541,7 @@ export class Frame extends SdkObject { super(page, 'frame'); this.attribution.frame = this; this.seq = page.frameManager._allocateFrameSeq(); + this.webmcp = new WebMCP(this); this._id = id; this._page = page; this._parentFrame = parentFrame; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 314d13e1becdd..b66220abd88f0 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -41,6 +41,7 @@ import * as rawBindingsControllerSource from '../generated/bindingsControllerSou import { Overlay } from './overlay'; import { NonRecoverableDOMError } from './dom'; import { Screencast } from './screencast'; +import { WebMCPInstrumentation } from './webmcp'; import { saveGlobalsSnapshotSource } from './javascript'; import type { Artifact } from './artifact'; @@ -95,6 +96,8 @@ export interface PageDelegate { pdf?: (options: channels.PagePdfParams) => Promise; coverage?: () => any; noUtilityWorld?: () => boolean; + enableWebMCP?(): Promise; + callWebMCPTool?(progress: Progress, frame: frames.Frame, name: string, input: unknown): Promise; // Work around WebKit's raf issues on Windows. rafCountForStablePosition(): number; @@ -201,6 +204,7 @@ export class Page extends SdkObject { readonly overlay: Overlay; readonly screencast: Screencast; + readonly webmcpInstrumentation: WebMCPInstrumentation; _closeReason: string | undefined; private _customCloseHandler?: (runBeforeUnload: boolean) => Promise; @@ -217,6 +221,7 @@ export class Page extends SdkObject { this.frameManager = new frames.FrameManager(this); this.overlay = new Overlay(this); this.screencast = new Screencast(this); + this.webmcpInstrumentation = new WebMCPInstrumentation(this); if (delegate.pdf) this.pdf = delegate.pdf.bind(delegate); this.coverage = delegate.coverage ? delegate.coverage() : null; diff --git a/packages/playwright-core/src/server/webmcp.ts b/packages/playwright-core/src/server/webmcp.ts new file mode 100644 index 0000000000000..1431304ad4760 --- /dev/null +++ b/packages/playwright-core/src/server/webmcp.ts @@ -0,0 +1,276 @@ +/** + * 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. + */ + +import { kBindingsControllerProperty } from '@isomorphic/utilityScriptSerializers'; +import * as rawWebMCPSource from '../generated/webMCPSource'; +import { Frame } from './frames'; + +import type { InitScript, Page, PageBinding } from './page'; +import type { Progress } from './progress'; + +export type WebMCPToolAnnotations = { + readOnly?: boolean; + untrustedContent?: boolean; + consequential?: boolean; +}; + +export type WebMCPToolInfo = { + name: string; + description: string; + inputSchema?: unknown; + annotations?: WebMCPToolAnnotations; +}; + +export type RawWebMCPTool = { + name: string; + description: string; + inputSchema?: unknown; + annotations?: WebMCPToolAnnotations; +}; + +const kToolsChangedBinding = '__pw_webmcpToolsChanged'; +const kScriptProperty = '__pw_webmcp'; + +const kScriptOptions = JSON.stringify({ + property: kScriptProperty, + bindingName: kToolsChangedBinding, + bindingsControllerProperty: kBindingsControllerProperty, +}); + +const kInstallSource = `(() => { + const module = {}; + ${rawWebMCPSource.source} + return module.exports.WebMCPScript().install(globalThis, ${kScriptOptions}); +})()`; + +export class WebMCP { + private _frame: Frame; + private _tools = new Map(); + private _enabled: Promise | undefined; + private _lastSignature: string | undefined; + private _changeScheduled = false; + + constructor(frame: Frame) { + this._frame = frame; + frame.on(Frame.Events.InternalNavigation, event => { + if (event.newDocument && !event.error) + this.toolsReported([]); + }); + } + + async enable(progress: Progress) { + if (!this._enabled) { + assertBrowserSupport(this._frame._page); + const enabled = this._enable(progress); + this._enabled = enabled; + enabled.catch(() => { + if (this._enabled === enabled) + this._enabled = undefined; + }); + } + await progress.race(this._enabled); + } + + async disable(progress: Progress) { + const enabled = this._enabled; + if (!enabled) + return; + try { + await progress.race(enabled); + } catch { + } + if (this._enabled !== enabled) + return; + this._enabled = undefined; + this._lastSignature = undefined; + await this._frame._page.webmcpInstrumentation.release(progress, this._frame); + } + + async tools(progress: Progress): Promise { + await progress.race(this._enabledPromise()); + return this._snapshot(); + } + + async callTool(progress: Progress, name: string, input: unknown): Promise { + const native = await progress.race(this._enabledPromise()); + if (!this._tools.has(name)) { + const available = [...this._tools.keys()]; + throw new Error(`No WebMCP tool named "${name}".` + + (available.length ? ` Available tools: ${available.join(', ')}.` : ' The frame does not register any WebMCP tools.')); + } + if (native) + return await this._frame._page.delegate.callWebMCPTool!(progress, this._frame, name, input ?? {}); + return await this._callToolInPage(progress, name, input ?? {}); + } + + toolsAdded(tools: RawWebMCPTool[]) { + for (const tool of tools) + this._tools.set(tool.name, normalizeTool(tool)); + this._scheduleChanged(); + } + + toolsRemoved(names: string[]) { + for (const name of names) + this._tools.delete(name); + this._scheduleChanged(); + } + + toolsReported(tools: RawWebMCPTool[]) { + this._tools = new Map(tools.map(tool => [tool.name, normalizeTool(tool)])); + this._scheduleChanged(); + } + + private _enabledPromise(): Promise { + if (!this._enabled) + throw new Error('WebMCP is not enabled. Call webmcp.enable() first.'); + return this._enabled; + } + + private async _enable(progress: Progress): Promise { + const page = this._frame._page; + const native = await progress.race(page.delegate.enableWebMCP?.() ?? Promise.resolve(false)); + if (!native) + this.toolsReported(await page.webmcpInstrumentation.acquire(progress, this._frame)); + this._lastSignature = this._signature(this._snapshot()); + return native; + } + + private _scheduleChanged() { + if (this._changeScheduled) + return; + this._changeScheduled = true; + queueMicrotask(() => { + this._changeScheduled = false; + this._emitIfChanged(); + }); + } + + private _emitIfChanged() { + if (this._lastSignature === undefined) + return; + const tools = this._snapshot(); + const signature = this._signature(tools); + if (signature === this._lastSignature) + return; + this._lastSignature = signature; + this._frame.emit(Frame.Events.WebMCPToolsChanged, tools); + } + + private _signature(tools: WebMCPToolInfo[]): string { + return JSON.stringify(tools.map(tool => [tool.name, tool.description, tool.inputSchema ?? null, tool.annotations ?? null])); + } + + private _snapshot(): WebMCPToolInfo[] { + return [...this._tools.values()].map(tool => ({ ...tool })); + } + + private async _callToolInPage(progress: Progress, name: string, input: unknown): Promise { + const resultJson: string = await this._frame.evaluateExpression(progress, `params => { + const script = globalThis[params.property]; + if (!script) + throw new Error('WebMCP is not available in this frame'); + return script.callTool(params.name, params.inputJson); + }`, { isFunction: true }, { property: kScriptProperty, name, inputJson: JSON.stringify(input) }); + return JSON.parse(resultJson); + } +} + +// Page-wide hooks for browsers that do not report tool registrations natively. +// Init scripts and bindings cover every frame, the frames pick out their own reports. +export class WebMCPInstrumentation { + private _page: Page; + private _frames = new Set(); + private _installed: Promise<{ binding: PageBinding, initScript: InitScript }> | undefined; + + constructor(page: Page) { + this._page = page; + } + + async acquire(progress: Progress, frame: Frame): Promise { + if (!this._installed) { + const installed = this._install(progress); + this._installed = installed; + installed.catch(() => { + if (this._installed === installed) + this._installed = undefined; + }); + } + await progress.race(this._installed); + this._frames.add(frame); + // The current document registered its tools before the hooks were in place, or while nobody listened. + return await progress.race(frame.nonStallingEvaluateInExistingContext(kInstallSource, 'main').catch(() => [])); + } + + async release(progress: Progress, frame: Frame) { + this._frames.delete(frame); + if (this._frames.size || !this._installed) + return; + const installed = this._installed; + this._installed = undefined; + let hooks: { binding: PageBinding, initScript: InitScript }; + try { + hooks = await progress.race(installed); + } catch { + return; + } + await progress.race(Promise.all([hooks.binding.dispose(), hooks.initScript.dispose()])); + } + + private async _install(progress: Progress) { + const binding = await this._page.exposeBinding(progress, kToolsChangedBinding, ({ frame }, tools: RawWebMCPTool[]) => frame.webmcp.toolsReported(tools), true); + try { + const initScript = await this._page.addInitScript(progress, kInstallSource); + return { binding, initScript }; + } catch (error) { + binding.dispose().catch(() => {}); + throw error; + } + } +} + +function assertBrowserSupport(page: Page) { + const { name, browserType, browserProcess, originalLaunchOptions } = page.browserContext._browser.options; + if (browserType === 'webkit') + throw new Error('WebMCP is not supported in WebKit.'); + // Only a browser launched by Playwright knows its launch options. + if (!browserProcess.process || (name !== 'chromium' && name !== 'firefox')) + return; + if (browserType === 'chromium') { + const features = (originalLaunchOptions.args ?? []) + .filter(arg => arg.startsWith('--enable-features=')) + .flatMap(arg => arg.substring('--enable-features='.length).split(',')); + if (!features.includes('WebMCP')) + throw new Error('WebMCP is not enabled. Launch the browser with the "--enable-features=WebMCP" argument.'); + } else if (originalLaunchOptions.firefoxUserPrefs?.['dom.modelcontext.enabled'] !== true) { + throw new Error('WebMCP is not enabled. Launch the browser with the "dom.modelcontext.enabled" preference set to true.'); + } +} + +function normalizeTool(tool: RawWebMCPTool): WebMCPToolInfo { + const annotations: WebMCPToolAnnotations = {}; + if (tool.annotations?.readOnly) + annotations.readOnly = true; + if (tool.annotations?.untrustedContent) + annotations.untrustedContent = true; + if (tool.annotations?.consequential) + annotations.consequential = true; + return { + name: tool.name, + description: tool.description, + ...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}), + ...(Object.keys(annotations).length ? { annotations } : {}), + }; +} diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index da326c2721a56..e9b3a4db41712 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -140,6 +140,12 @@ export class Tab extends EventEmitter { this._downloadStarted(download).catch(e => debug('pw:tools:error')(e)); }), ]; + if (context.config.webmcp !== false) { + this._disposables.push( + eventsHelper.addEventListener(p, 'frameattached', frame => this._enableWebMCP(frame)), + eventsHelper.addEventListener(p, 'framedetached', () => this._refreshWebMCPTools()), + ); + } // eslint-disable-next-line no-restricted-syntax (page as any)[tabSymbol] = this; const wallTime = Date.now(); @@ -176,6 +182,8 @@ export class Tab extends EventEmitter { } private async _initialize() { + if (this.context.config.webmcp !== false) + await Promise.all(this.page.frames().map(frame => this._enableWebMCP(frame))); for (const message of await Tab.collectConsoleMessages(this.page)) this._handleConsoleMessage(message); const requests = await this.page.requests().catch(() => []); @@ -490,6 +498,21 @@ export class Tab extends EventEmitter { return this._webmcpTools; } + private async _enableWebMCP(frame: playwright.Frame) { + try { + await frame.webmcp.enable(); + } catch (e) { + debug('pw:tools:error')(e); + return; + } + // The browser reports registrations as they happen, so the MCP tool list follows the page. + frame.webmcp.on('toolschanged', () => this._refreshWebMCPTools()); + } + + private _refreshWebMCPTools() { + this.updateWebMCPTools().catch(e => debug('pw:tools:error')(e)); + } + async updateWebMCPTools(): Promise { if (this.context.config.webmcp === false) return; diff --git a/packages/playwright-core/src/tools/backend/webmcp.ts b/packages/playwright-core/src/tools/backend/webmcp.ts index 3f40295d22aac..7cb6f17500ab7 100644 --- a/packages/playwright-core/src/tools/backend/webmcp.ts +++ b/packages/playwright-core/src/tools/backend/webmcp.ts @@ -23,11 +23,10 @@ import type * as mcpServer from '../utils/mcp/server'; import type { Tab } from './tab'; import type * as playwright from '../../..'; -const kFrameTimeout = 5000; +const kListTimeout = 5000; export type WebMCPToolInfo = { name: string; - title?: string; description: string; inputSchema?: unknown; annotations?: { @@ -35,7 +34,6 @@ export type WebMCPToolInfo = { untrustedContent?: boolean; consequential?: boolean; }; - origin?: string; frameUrl: string; // Identifies the registering frame in tool output and in the call parameters. frameLabel: string; @@ -63,104 +61,10 @@ export type WebMCPListing = { tools: WebMCPToolInfo[]; }; -// Not in lib.dom.d.ts. Chromium exposes the entry point on `document`, Firefox's -// prototype still exposes it on `navigator`. -type PageRegisteredTool = { - name: string; - title?: string; - description?: string; - inputSchema?: unknown; - annotations?: Record; - origin?: string; - window?: Window; -}; - -type PageModelContext = { - getTools?: () => Promise; - executeTool?: (tool: PageRegisteredTool, input: object | string) => Promise; - invokeTool?: (name: string, input: unknown) => Promise; -}; - -type DocumentWithModelContext = Document & { modelContext?: PageModelContext }; -type NavigatorWithModelContext = Navigator & { modelContext?: PageModelContext }; - -function collectToolsInPage() { - const modelContext = (document as DocumentWithModelContext).modelContext - ?? (navigator as NavigatorWithModelContext).modelContext; - if (!modelContext?.getTools) - return null; - return Promise.resolve(modelContext.getTools()).then(tools => tools.filter(tool => { - // Chromium's getTools() aggregates same-origin descendant frames, Firefox's does not. - // Keeping only the tools this frame owns makes the per-frame results disjoint, so - // stitching them together does not double-count. - return !('window' in tool) || tool.window === window; - }).map(tool => { - let inputSchema = tool.inputSchema; - if (typeof inputSchema === 'string') { - // Chromium hands the schema back as a JSON string, Firefox as an object. - try { - inputSchema = JSON.parse(inputSchema); - } catch { - inputSchema = undefined; - } - } - const annotations = tool.annotations; - return { - name: tool.name, - title: tool.title || undefined, - description: tool.description ?? '', - 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, - origin: tool.origin, - }; - })); -} - -function callToolInPage(params: { name: string, inputJson: string }) { - const modelContext = (document as DocumentWithModelContext).modelContext - ?? (navigator as NavigatorWithModelContext).modelContext; - if (!modelContext) - throw new Error('WebMCP is not available on this page'); - const stringify = (result: unknown) => result === undefined ? 'null' : JSON.stringify(result); - if (modelContext.executeTool) { - // Chromium: executeTool(registeredTool, input) resolves to a JSON string. Chromium 155+ takes - // the input as an object, older versions as a JSON string. - return Promise.resolve(modelContext.getTools!()).then(tools => { - const tool = tools.filter(t => !('window' in t) || t.window === window).find(t => t.name === params.name); - if (!tool) - throw new Error(`WebMCP tool "${params.name}" is not registered in this frame`); - return Promise.resolve().then(() => modelContext.executeTool!(tool, JSON.parse(params.inputJson))).catch(e => { - if (String(e?.message).includes('Failed to parse input arguments')) - return modelContext.executeTool!(tool, params.inputJson); - throw e; - }); - }).then(result => typeof result === 'string' ? result : stringify(result)); - } - // Firefox: invokeTool(name, inputObject) resolves to the value itself. - return Promise.resolve(modelContext.invokeTool!(params.name, JSON.parse(params.inputJson))).then(stringify); -} - -const kTimedOut = Symbol('timedOut'); - -async function withTimeout(promise: Promise, timeout: number): Promise { - let timer: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise(resolve => { - timer = setTimeout(() => resolve(kTimedOut), timeout); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - clearTimeout(timer); - } -} - export async function listWebMCPTools(tab: Tab): Promise { const frames = tab.page.frames(); + // Frames that are not enabled or are stuck report no tools. + const toolsPerFrame = await Promise.all(frames.map(frame => frame.webmcp.tools({ timeout: kListTimeout }).catch(() => []))); // Several frames can share a URL, for example a widget embedded twice, and each one has its // own model context that can register the same tool name. Fall back to the frame's position // in that case, so that every frame that owns tools can still be addressed. @@ -168,33 +72,18 @@ export async function listWebMCPTools(tab: Tab): Promise { for (const frame of frames) urlCounts.set(frame.url(), (urlCounts.get(frame.url()) ?? 0) + 1); - const results = await Promise.all(frames.map(async (frame, frameIndex) => { - const frameUrl = frame.url(); - const frameLabel = urlCounts.get(frameUrl)! > 1 ? `${frameUrl} (frame ${frameIndex})` : frameUrl; - // A detached or navigating frame rejects, which is indistinguishable from - // "no model context here" for our purposes. - const collected = await withTimeout(frame.evaluate(collectToolsInPage).catch(() => null), kFrameTimeout); - // A frame that times out simply contributes no tools. - if (collected === kTimedOut || !collected) - return { frame, frameUrl, frameLabel, tools: [] }; - const tools: CollectedTool[] = collected.map(tool => ({ - ...tool, - annotations: tool.annotations && Object.values(tool.annotations).some(value => value !== undefined) ? tool.annotations : undefined, - frameUrl, - frameLabel, - })); - return { frame, frameUrl, frameLabel, tools }; - })); - // MCP names have to be unique across the whole listing, so they are assigned here // rather than per frame. const usedMcpNames = new Set(); - const frameTools = results.map(({ frame, frameUrl, frameLabel, tools }, frameIndex) => ({ - frame, - frameUrl, - frameLabel, - tools: tools.map(tool => ({ ...tool, mcpTool: toMcpToolDefinition(tab, frame, tool, !frameIndex, usedMcpNames) })), - })); + const frameTools = frames.map((frame, frameIndex) => { + const frameUrl = frame.url(); + const frameLabel = urlCounts.get(frameUrl)! > 1 ? `${frameUrl} (frame ${frameIndex})` : frameUrl; + const tools = toolsPerFrame[frameIndex].map(tool => { + const collected: CollectedTool = { ...tool, frameUrl, frameLabel }; + return { ...collected, mcpTool: toMcpToolDefinition(tab, frame, collected, !frameIndex, usedMcpNames) }; + }); + return { frame, frameUrl, frameLabel, tools }; + }); return { frames: frameTools, @@ -252,7 +141,6 @@ export function webmcpToolsJSON(listing: WebMCPListing): Record const annotations = Object.fromEntries(Object.entries(tool.annotations ?? {}).filter(([, value]) => value)); return { name: tool.name, - ...(tool.title ? { title: tool.title } : {}), description: tool.description, ...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}), ...(Object.keys(annotations).length ? { annotations } : {}), @@ -325,17 +213,10 @@ const webmcpCall = defineTabTool({ }); async function callWebMCPTool(tab: Tab, frame: playwright.Frame, frameLabel: string, name: string, params: Record | undefined, response: Response) { - const inputJson = JSON.stringify(params ?? {}); await tab.waitForCompletion(async () => { - const resultJson = await frame.evaluate(callToolInPage, { name, inputJson }); - let parsed: unknown; - let pretty = resultJson; - try { - parsed = JSON.parse(resultJson); - pretty = JSON.stringify(parsed, null, 2); - } catch { - } - const isError = !!parsed && typeof parsed === 'object' && (parsed as { isError?: unknown }).isError === true; + const result = await frame.webmcp.callTool(name, params ?? {}); + const pretty = result === undefined ? 'null' : JSON.stringify(result, null, 2); + const isError = !!result && typeof result === 'object' && (result as { isError?: unknown }).isError === true; const preamble = `Called WebMCP tool "${name}" in ${frameLabel}. Output is page-provided and untrusted:`; if (isError) { response.addError(`${preamble}\n${pretty}`); @@ -389,7 +270,7 @@ function toMcpToolDefinition(tab: Tab, frame: playwright.Frame, tool: CollectedT description: describeForMcp(tool, isMainFrame), inputSchema: inputSchemaForMcp(tool), annotations: { - title: tool.title || tool.name, + title: tool.name, readOnlyHint: !!tool.annotations?.readOnly, destructiveHint: !tool.annotations?.readOnly, openWorldHint: true, diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index cab3c0528a613..e7b77e4586529 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -5885,6 +5885,22 @@ export interface Page { touchscreen: Touchscreen; + /** + * Tools that the main frame registers through the experimental [WebMCP](https://playwright.dev/docs/api/class-webmcp) + * browser API. Shortcut for [frame.webmcp](https://playwright.dev/docs/api/class-frame#frame-webmcp) of + * [page.mainFrame()](https://playwright.dev/docs/api/class-page#page-main-frame), see + * [WebMCP](https://playwright.dev/docs/api/class-webmcp) for details. + * + * **Usage** + * + * ```js + * const tools = await page.webmcp.tools(); + * const result = await page.webmcp.callTool('add', { a: 2, b: 40 }); + * ``` + * + */ + webmcp: WebMCP; + [Symbol.asyncDispose](): Promise; } @@ -9139,6 +9155,12 @@ export interface Frame { */ waitUntil?: "load"|"domcontentloaded"|"networkidle"|"commit"; }): Promise; + + /** + * Tools that the frame registers through the experimental [WebMCP](https://playwright.dev/docs/api/class-webmcp) + * browser API. See [WebMCP](https://playwright.dev/docs/api/class-webmcp) for details. + */ + webmcp: WebMCP; } /** @@ -23668,6 +23690,528 @@ export interface WebError { page(): null|Page; } +/** + * [WebMCP](https://playwright.dev/docs/api/class-webmcp) exposes the tools that a frame registers through the + * experimental [WebMCP](https://playwright.dev/docs/api/class-webmcp) browser API, `navigator.modelContext`. It lists + * the tools, reports when the set of tools changes, and calls the tools. + * + * Instances are accessed through [frame.webmcp](https://playwright.dev/docs/api/class-frame#frame-webmcp). + * [page.webmcp](https://playwright.dev/docs/api/class-page#page-webmcp) is the instance of the main frame. Call + * [webMCP.enable()](https://playwright.dev/docs/api/class-webmcp#web-mcp-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 }); + * ``` + * + */ +export interface WebMCP { + /** + * 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 [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools) returns. + * + * ```js + * page.webmcp.on('toolschanged', tools => { + * console.log('tools are now', tools.map(tool => tool.name)); + * }); + * ``` + * + */ + on(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * 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 [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools) returns. + * + * ```js + * page.webmcp.on('toolschanged', tools => { + * console.log('tools are now', tools.map(tool => tool.name)); + * }); + * ``` + * + */ + addListener(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * 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 [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools) returns. + * + * ```js + * page.webmcp.on('toolschanged', tools => { + * console.log('tools are now', tools.map(tool => tool.name)); + * }); + * ``` + * + */ + prependListener(event: 'toolschanged', listener: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => any): this; + + /** + * 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 name Name of the tool, as reported by + * [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools). + * @param input Input for the tool, matching its `inputSchema`. Defaults to an empty object. + * @param options + */ + callTool(name: string, input?: Serializable, options?: { + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + + /** + * Stops tracking the tools that the frame registers and stops emitting + * [webMCP.on('toolschanged')](https://playwright.dev/docs/api/class-webmcp#web-mcp-event-tools-changed). Disposing + * the [Disposable](https://playwright.dev/docs/api/class-disposable) returned by + * [webMCP.enable()](https://playwright.dev/docs/api/class-webmcp#web-mcp-enable) does the same. + */ + disable(): Promise; + + /** + * Starts tracking the tools that the frame registers, so that + * [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools), + * [webMCP.callTool(name[, input, options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-call-tool) and + * [webMCP.on('toolschanged')](https://playwright.dev/docs/api/class-webmcp#web-mcp-event-tools-changed) work. Throws + * if the browser was launched without WebMCP support, see the note above for the launch options that enable it. + * Returns a [Disposable](https://playwright.dev/docs/api/class-disposable) that disables the tracking again. + * + * Tracking is per frame. [page.webmcp](https://playwright.dev/docs/api/class-page#page-webmcp) covers the main frame + * only, child frames are tracked through their own + * [frame.webmcp](https://playwright.dev/docs/api/class-frame#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()); + * ``` + * + */ + enable(): Promise; + + /** + * Returns the tools currently registered by the frame. + * @param options + */ + tools(options?: { + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise>; + + /** + * 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 [webMCP.tools([options])](https://playwright.dev/docs/api/class-webmcp#web-mcp-tools) returns. + * + * ```js + * page.webmcp.on('toolschanged', tools => { + * console.log('tools are now', tools.map(tool => tool.name)); + * }); + * ``` + * + */ + waitForEvent(event: 'toolschanged', optionsOrPredicate?: { predicate?: (data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((data: Array<{ + /** + * Tool name, unique within the frame. + */ + name: string; + + /** + * Tool description. + */ + description: string; + + /** + * JSON Schema of the tool input, when the page provides one. + */ + inputSchema?: Serializable; + + /** + * Hints the page provides about the tool. + */ + annotations?: { + /** + * The tool does not modify any state. + */ + readOnly?: boolean; + + /** + * The tool output may contain third-party content. + */ + untrustedContent?: boolean; + + /** + * The tool takes a consequential action, such as placing an order. + */ + consequential?: boolean; + }; + }>) => boolean | Promise)): Promise>; + +} + /** * The [WebSocket](https://playwright.dev/docs/api/class-websocket) class represents WebSocket connections within a * page. It provides the ability to inspect and manipulate the data being transmitted and received. diff --git a/packages/protocol/spec/frame.yml b/packages/protocol/spec/frame.yml index 4f94207b9e924..e0bea36e2ee22 100644 --- a/packages/protocol/spec/frame.yml +++ b/packages/protocol/spec/frame.yml @@ -909,6 +909,41 @@ Frame: snapshot: true pause: true + webmcpEnable: + title: Enable WebMCP + group: configuration + + webmcpDisable: + title: Disable WebMCP + group: configuration + + webmcpTools: + title: List WebMCP tools + group: getter + returns: + tools: + type: array + items: + type: object + properties: + name: string + description: string + inputSchema: json? + annotations: + type: object? + properties: + readOnly: boolean? + untrustedContent: boolean? + consequential: boolean? + + webmcpCallTool: + title: Call WebMCP tool + parameters: + name: string + input: json? + returns: + result: json? + events: loadstate: @@ -925,3 +960,20 @@ Frame: properties: request: Request? error: string? + + webmcpToolsChanged: + parameters: + tools: + type: array + items: + type: object + properties: + name: string + description: string + inputSchema: json? + annotations: + type: object? + properties: + readOnly: boolean? + untrustedContent: boolean? + consequential: boolean? diff --git a/packages/protocol/spec/page.yml b/packages/protocol/spec/page.yml index 8c522a2549509..be58094ebe7df 100644 --- a/packages/protocol/spec/page.yml +++ b/packages/protocol/spec/page.yml @@ -785,3 +785,4 @@ Page: worker: parameters: worker: Worker + diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 0e6061c6adbd7..81b4f86a51e38 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -1245,6 +1245,18 @@ scheme.FrameNavigatedEvent = tObject({ })), error: tOptional(tString), }); +scheme.FrameWebmcpToolsChangedEvent = tObject({ + tools: tArray(tObject({ + name: tString, + description: tString, + inputSchema: tOptional(tAny), + annotations: tOptional(tObject({ + readOnly: tOptional(tBoolean), + untrustedContent: tOptional(tBoolean), + consequential: tOptional(tBoolean), + })), + })), +}); scheme.FrameEvalOnSelectorParams = tObject({ selector: tString, strict: tOptional(tBoolean), @@ -1664,6 +1676,30 @@ scheme.FrameExpectErrorDetails = tObject({ timedOut: tOptional(tBoolean), customErrorMessage: tOptional(tString), }); +scheme.FrameWebmcpEnableParams = tOptional(tObject({})); +scheme.FrameWebmcpEnableResult = tOptional(tObject({})); +scheme.FrameWebmcpDisableParams = tOptional(tObject({})); +scheme.FrameWebmcpDisableResult = tOptional(tObject({})); +scheme.FrameWebmcpToolsParams = tOptional(tObject({})); +scheme.FrameWebmcpToolsResult = tObject({ + tools: tArray(tObject({ + name: tString, + description: tString, + inputSchema: tOptional(tAny), + annotations: tOptional(tObject({ + readOnly: tOptional(tBoolean), + untrustedContent: tOptional(tBoolean), + consequential: tOptional(tBoolean), + })), + })), +}); +scheme.FrameWebmcpCallToolParams = tObject({ + name: tString, + input: tOptional(tAny), +}); +scheme.FrameWebmcpCallToolResult = tObject({ + result: tOptional(tAny), +}); scheme.JSHandleInitializer = tObject({ preview: tString, }); diff --git a/tests/library/webmcp-disabled.spec.ts b/tests/library/webmcp-disabled.spec.ts new file mode 100644 index 0000000000000..94b20aaa3ae66 --- /dev/null +++ b/tests/library/webmcp-disabled.spec.ts @@ -0,0 +1,26 @@ +/** + * 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. + */ + +import { browserTest as test, expect } from '../config/browserTest'; + +test.skip(({ browserName }) => browserName === 'webkit', 'WebKit does not implement WebMCP'); +test.skip(({ isBidi }) => isBidi, 'WebMCP is not implemented over BiDi'); + +test('should throw when the browser was launched without WebMCP', async ({ page, browserName }) => { + const error = await page.webmcp.enable().catch(e => e); + expect(error.message).toContain('WebMCP is not enabled.'); + expect(error.message).toContain(browserName === 'firefox' ? 'dom.modelcontext.enabled' : '--enable-features=WebMCP'); +}); diff --git a/tests/library/webmcp.spec.ts b/tests/library/webmcp.spec.ts new file mode 100644 index 0000000000000..cb88dd5c8a952 --- /dev/null +++ b/tests/library/webmcp.spec.ts @@ -0,0 +1,320 @@ +/** + * 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. + */ + +import { browserTest as test, expect } from '../config/browserTest'; + +test.use({ + launchOptions: async ({ launchOptions, browserName }, use) => { + if (browserName === 'firefox') { + await use({ + ...launchOptions, + firefoxUserPrefs: { + ...launchOptions.firefoxUserPrefs, + 'dom.modelcontext.enabled': true, + 'dom.modelcontext.testing.enabled': true, + }, + }); + return; + } + await use({ ...launchOptions, args: [...(launchOptions.args ?? []), '--enable-features=WebMCP'] }); + }, +}); + +test.skip(({ browserName }) => browserName === 'webkit', 'WebKit does not implement WebMCP'); +test.skip(({ isBidi }) => isBidi, 'WebMCP is not implemented over BiDi'); + +function registerScript(tools: string) { + return ``; +} + +const kAdd = ` + modelContext.registerTool({ + name: 'add', + description: 'Adds two numbers', + inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }, + annotations: { readOnlyHint: true }, + async execute(input) { return { content: [{ type: 'text', text: String(input.a + input.b) }] }; }, + }); +`; + +const kAddSchema = { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }; + +function serve(server: any, path: string, body: string) { + server.setRoute(path, (req: any, res: any) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`WebMCP${body}`); + }); +} + +test('should list tools registered by the page', async ({ page, server }) => { + serve(server, '/', registerScript(kAdd + ` + modelContext.registerTool({ + name: 'noop', + description: 'Does nothing', + async execute() {}, + }); + `)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + // Browsers list tools in their own order. + const tools = (await page.webmcp.tools()).sort((a, b) => a.name.localeCompare(b.name)); + expect(tools).toEqual([ + { + name: 'add', + description: 'Adds two numbers', + inputSchema: kAddSchema, + annotations: { readOnly: true }, + }, + { + name: 'noop', + description: 'Does nothing', + }, + ]); +}); + +test('should report no tools when the page registers none', async ({ page, server }) => { + await page.webmcp.enable(); + await page.goto(server.EMPTY_PAGE); + expect(await page.webmcp.tools()).toEqual([]); +}); + +test('should call a tool', async ({ page, server }) => { + serve(server, '/', registerScript(kAdd)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + expect(await page.webmcp.callTool('add', { a: 2, b: 40 })).toEqual({ content: [{ type: 'text', text: '42' }] }); +}); + +test('should return isError results as is', async ({ page, server }) => { + serve(server, '/', registerScript(` + modelContext.registerTool({ + name: 'broken', + description: 'Always fails', + async execute() { return { content: [{ type: 'text', text: 'nope' }], isError: true }; }, + }); + `)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + expect(await page.webmcp.callTool('broken')).toEqual({ content: [{ type: 'text', text: 'nope' }], isError: true }); +}); + +test('should throw when the tool throws', async ({ page, server }) => { + serve(server, '/', registerScript(` + modelContext.registerTool({ + name: 'throwing', + description: 'Throws', + async execute() { throw new Error('boom'); }, + }); + `)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + const error = await page.webmcp.callTool('throwing').catch(e => e); + expect(error.message).toContain('boom'); +}); + +test('should throw for an unknown tool', async ({ page, server }) => { + serve(server, '/', registerScript(kAdd)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + const error = await page.webmcp.callTool('missing').catch(e => e); + expect(error.message).toContain('No WebMCP tool named "missing"'); + expect(error.message).toContain('Available tools: add'); +}); + +test('should time out when the tool does not settle', async ({ page, server }) => { + serve(server, '/', registerScript(` + modelContext.registerTool({ + name: 'stuck', + description: 'Never resolves', + execute() { return new Promise(() => {}); }, + }); + `)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + const error = await page.webmcp.callTool('stuck', {}, { timeout: 500 }).catch(e => e); + expect(error.message).toContain('Timeout 500ms exceeded'); +}); + +test('should drop tools after navigation', async ({ page, server }) => { + serve(server, '/', registerScript(kAdd)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + expect(await page.webmcp.tools()).toHaveLength(1); + + await page.goto(server.EMPTY_PAGE); + expect(await page.webmcp.tools()).toEqual([]); + const error = await page.webmcp.callTool('add', { a: 1, b: 1 }).catch(e => e); + expect(error.message).toContain('The frame does not register any WebMCP tools'); +}); + +test('should emit toolschanged when navigating away from the tools', async ({ page, server }) => { + serve(server, '/', registerScript(kAdd)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + expect(await page.webmcp.tools()).toHaveLength(1); + + const changed = page.webmcp.waitForEvent('toolschanged'); + await page.goto(server.EMPTY_PAGE); + expect(await changed).toEqual([]); +}); + +test('should list tools registered before enable', async ({ page, server }) => { + serve(server, '/', registerScript(kAdd)); + await page.goto(server.PREFIX + '/'); + + await page.webmcp.enable(); + expect(await page.webmcp.tools()).toEqual([expect.objectContaining({ name: 'add' })]); + expect(await page.webmcp.callTool('add', { a: 1, b: 2 })).toEqual({ content: [{ type: 'text', text: '3' }] }); +}); + +test('should throw when used before enable', async ({ page }) => { + const error = await page.webmcp.tools().catch(e => e); + expect(error.message).toContain('WebMCP is not enabled. Call webmcp.enable() first.'); +}); + +test('should stop after disable and resume after enable', async ({ page, server }) => { + serve(server, '/', registerScript(kAdd)); + const disposable = await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + expect(await page.webmcp.tools()).toHaveLength(1); + + const events: string[][] = []; + page.webmcp.on('toolschanged', tools => events.push(tools.map(tool => tool.name))); + await disposable.dispose(); + const error = await page.webmcp.tools().catch(e => e); + expect(error.message).toContain('WebMCP is not enabled.'); + await page.evaluate(() => { + const modelContext = (document as any).modelContext || (navigator as any).modelContext; + modelContext.registerTool({ name: 'late', description: 'Registered later', async execute() {} }); + }); + await page.goto(server.EMPTY_PAGE); + await page.goto(server.PREFIX + '/'); + expect(events).toEqual([]); + + await page.webmcp.enable(); + expect((await page.webmcp.tools()).map(tool => tool.name)).toEqual(['add']); + const changed = page.webmcp.waitForEvent('toolschanged'); + await page.goto(server.EMPTY_PAGE); + expect(await changed).toEqual([]); +}); + +test('should emit toolschanged when the page unregisters a tool', async ({ page, server, browserName }) => { + test.skip(browserName !== 'firefox', 'Chromium does not implement unregisterTool'); + serve(server, '/', registerScript(kAdd)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + expect(await page.webmcp.tools()).toHaveLength(1); + + const changed = page.webmcp.waitForEvent('toolschanged'); + await page.evaluate(() => (navigator as any).modelContext.unregisterTool('add')); + expect(await changed).toEqual([]); +}); + +test('should emit toolschanged when the page registers a tool', async ({ page, server }) => { + serve(server, '/', registerScript(kAdd)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + const events: string[][] = []; + page.webmcp.on('toolschanged', tools => events.push(tools.map(tool => tool.name))); + const changed = page.webmcp.waitForEvent('toolschanged'); + await page.evaluate(() => { + const modelContext = (document as any).modelContext || (navigator as any).modelContext; + modelContext.registerTool({ name: 'late', description: 'Registered later', async execute() {} }); + }); + const tools = await changed; + expect(tools.map(tool => tool.name)).toEqual(['add', 'late']); + expect(events).toEqual([['add', 'late']]); +}); + +test('should list declarative tools', async ({ page, server, browserName }) => { + test.skip(browserName !== 'chromium', 'Only Chromium implements declarative tools'); + serve(server, '/', ` +
+ + +
+ `); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + expect(await page.webmcp.tools()).toEqual([expect.objectContaining({ + name: 'subscribe', + description: 'Subscribes to the newsletter', + inputSchema: expect.objectContaining({ type: 'object', properties: { email: { type: 'string' } } }), + })]); + + // A declarative tool waits for the user to submit the form. + const error = await page.webmcp.callTool('subscribe', { email: 'me@example.com' }, { timeout: 500 }).catch(e => e); + expect(error.message).toContain('Timeout 500ms exceeded'); + + const changed = page.webmcp.waitForEvent('toolschanged'); + await page.evaluate(() => document.querySelector('form')!.remove()); + expect(await changed).toEqual([]); +}); + +test('should scope tools to their frame', async ({ page, server, browserName }) => { + test.skip(browserName === 'firefox', 'Firefox does not support registering WebMCP tools in iframes yet, https://bugzilla.mozilla.org/show_bug.cgi?id=2019743'); + serve(server, '/', registerScript(kAdd) + ``); + serve(server, '/frame.html', registerScript(` + modelContext.registerTool({ + name: 'subscribe', + description: 'Subscribes to the newsletter', + async execute() { return { content: [{ type: 'text', text: 'subscribed' }] }; }, + }); + `)); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + + const childFrame = page.frames()[1]; + await childFrame.webmcp.enable(); + expect(await page.webmcp.tools()).toEqual([expect.objectContaining({ name: 'add' })]); + expect(await childFrame.webmcp.tools()).toEqual([expect.objectContaining({ name: 'subscribe' })]); + expect(await childFrame.webmcp.callTool('subscribe')).toEqual({ content: [{ type: 'text', text: 'subscribed' }] }); + + const error = await page.webmcp.callTool('subscribe').catch(e => e); + expect(error.message).toContain('No WebMCP tool named "subscribe"'); + expect(error.message).toContain('Available tools: add'); +}); + +test('should call same-name tools in their own frame', async ({ page, server, browserName }) => { + test.skip(browserName === 'firefox', 'Firefox does not support registering WebMCP tools in iframes yet, https://bugzilla.mozilla.org/show_bug.cgi?id=2019743'); + const registerEcho = (text: string) => registerScript(` + modelContext.registerTool({ + name: 'echo', + description: 'Echoes', + async execute() { return { content: [{ type: 'text', text: '${text}' }] }; }, + }); + `); + serve(server, '/', registerEcho('main') + ``); + serve(server, '/frame.html', registerEcho('frame')); + await page.webmcp.enable(); + await page.goto(server.PREFIX + '/'); + const childFrame = page.frames()[1]; + await childFrame.webmcp.enable(); + + expect(await page.webmcp.callTool('echo')).toEqual({ content: [{ type: 'text', text: 'main' }] }); + expect(await childFrame.webmcp.callTool('echo')).toEqual({ content: [{ type: 'text', text: 'frame' }] }); +}); diff --git a/utils/generate_injected.js b/utils/generate_injected.js index 86fb7cb39d3a1..0f13a11dcbc4c 100644 --- a/utils/generate_injected.js +++ b/utils/generate_injected.js @@ -66,6 +66,11 @@ const injectedScripts = [ path.join(ROOT, 'packages', 'injected', 'lib'), path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), ], + [ + path.join(ROOT, 'packages', 'injected', 'src', 'webMCP.ts'), + path.join(ROOT, 'packages', 'injected', 'lib'), + path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), + ], [ path.join(ROOT, 'packages', 'injected', 'src', 'bidiInsertText.ts'), path.join(ROOT, 'packages', 'injected', 'lib'),