From 0d7cf92ae23653d961577cb1ac7bbaa657f2606e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 28 Sep 2026 19:45:44 -0700 Subject: [PATCH] Revert "feat(webmcp): add page.webmcp API (#42979)" This reverts commit c2c87a2948887d65bfc4237968022c9e1fa0e926. --- docs/src/api/class-page.md | 14 - docs/src/api/class-webmcp.md | 151 ----- packages/isomorphic/protocolMetainfo.ts | 4 - packages/playwright-client/types/types.d.ts | 594 ------------------ .../playwright-core/src/client/channels.d.ts | 52 -- packages/playwright-core/src/client/events.ts | 4 - packages/playwright-core/src/client/page.ts | 3 - packages/playwright-core/src/client/webmcp.ts | 86 --- .../playwright-core/src/server/channels.d.ts | 52 -- .../src/server/chromium/crPage.ts | 77 --- .../src/server/dispatchers/pageDispatcher.ts | 23 - packages/playwright-core/src/server/page.ts | 9 - packages/playwright-core/src/server/webmcp.ts | 391 ------------ .../playwright-core/src/tools/backend/tab.ts | 8 - .../src/tools/backend/webmcp.ts | 158 ++++- packages/playwright-core/types/types.d.ts | 594 ------------------ packages/protocol/spec/page.yml | 55 -- packages/protocol/src/validator.ts | 39 -- tests/library/webmcp-disabled.spec.ts | 26 - tests/library/webmcp.spec.ts | 326 ---------- 20 files changed, 135 insertions(+), 2531 deletions(-) delete mode 100644 docs/src/api/class-webmcp.md delete mode 100644 packages/playwright-core/src/client/webmcp.ts delete mode 100644 packages/playwright-core/src/server/webmcp.ts delete mode 100644 tests/library/webmcp-disabled.spec.ts delete mode 100644 tests/library/webmcp.spec.ts diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index a5160177aaf6c..e73477edba32f 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -5925,20 +5925,6 @@ 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 page registers through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) browser API. 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 deleted file mode 100644 index adfee63a72fdf..0000000000000 --- a/docs/src/api/class-webmcp.md +++ /dev/null @@ -1,151 +0,0 @@ -# class: WebMCP -* since: v1.64 -* langs: js - -[WebMCP] exposes the tools that a page registers through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) browser API, `navigator.modelContext`. It lists the tools across all frames of the page, reports when the set of tools changes, and calls the tools. - -Instances are accessed through [`property: Page.webmcp`]. 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 that registered it. - - `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. - - `frame` <[Frame]> Frame that registered the tool. - -Emitted while WebMCP is enabled, whenever the set of tools registered by the page changes, for example when the page registers or unregisters a tool, or when a frame that registered tools 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 page 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. - -When the same tool name is registered in several frames, pass [`option: frame`] to pick one. - -```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.frame -* since: v1.64 -- `frame` <[Frame]> - -Frame that registered the tool. Required when the same tool name is registered in more than one frame. - -### option: WebMCP.callTool.timeout = %%-input-timeout-js-%% -* since: v1.64 - -## async method: WebMCP.disable -* since: v1.64 - -Stops tracking the tools that the page 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 page 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. - -Chromium reports tool registrations natively. Firefox does not, so Playwright instruments `navigator.modelContext` in every frame of 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 that registered it. - - `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. - - `frame` <[Frame]> Frame that registered the tool. - -Returns the tools currently registered by the page, across all of its frames. Tools registered by the main frame come first. - -### 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/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 3331701e9b885..102877079ee7d 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -307,10 +307,6 @@ export const methodMetainfo = new Map([ ['Page.webStorageSetItem', { title: 'Set WebStorage item', group: 'configuration', }], ['Page.webStorageRemoveItem', { title: 'Remove WebStorage item', group: 'configuration', }], ['Page.webStorageClear', { title: 'Clear WebStorage', group: 'configuration', }], - ['Page.webmcpEnable', { title: 'Enable WebMCP', group: 'configuration', }], - ['Page.webmcpDisable', { title: 'Disable WebMCP', group: 'configuration', }], - ['Page.webmcpTools', { title: 'List WebMCP tools', group: 'getter', }], - ['Page.webmcpCallTool', { title: 'Call WebMCP tool', }], ['Root.initialize', { internal: true, }], ['Playwright.newRequest', { title: 'Create request context', }], ['DebugController.initialize', { internal: true, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 35c65ca8aabb7..cab3c0528a613 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -5885,20 +5885,6 @@ export interface Page { touchscreen: Touchscreen; - /** - * Tools that the page 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. - * - * **Usage** - * - * ```js - * const tools = await page.webmcp.tools(); - * const result = await page.webmcp.callTool('add', { a: 2, b: 40 }); - * ``` - * - */ - webmcp: WebMCP; - [Symbol.asyncDispose](): Promise; } @@ -23682,586 +23668,6 @@ export interface WebError { page(): null|Page; } -/** - * [WebMCP](https://playwright.dev/docs/api/class-webmcp) exposes the tools that a page registers through the - * experimental [WebMCP](https://playwright.dev/docs/api/class-webmcp) browser API, `navigator.modelContext`. It lists - * the tools across all frames of the page, reports when the set of tools changes, and calls the tools. - * - * Instances are accessed through [page.webmcp](https://playwright.dev/docs/api/class-page#page-webmcp). 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 page changes, for example when the - * page registers or unregisters a tool, or when a frame that registered tools 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Emitted while WebMCP is enabled, whenever the set of tools registered by the page changes, for example when the - * page registers or unregisters a tool, or when a frame that registered tools 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Removes an event listener added by `on` or `addListener`. - */ - removeListener(event: 'toolschanged', listener: (data: Array<{ - /** - * Tool name, unique within the frame that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Removes an event listener added by `on` or `addListener`. - */ - off(event: 'toolschanged', listener: (data: Array<{ - /** - * Tool name, unique within the frame that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Emitted while WebMCP is enabled, whenever the set of tools registered by the page changes, for example when the - * page registers or unregisters a tool, or when a frame that registered tools 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Calls a tool registered by the page 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. - * - * When the same tool name is registered in several frames, pass - * [`frame`](https://playwright.dev/docs/api/class-webmcp#web-mcp-call-tool-option-frame) to pick one. - * - * ```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?: { - /** - * Frame that registered the tool. Required when the same tool name is registered in more than one frame. - */ - frame?: Frame; - - /** - * 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 page 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 page 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. - * - * Chromium reports tool registrations natively. Firefox does not, so Playwright instruments `navigator.modelContext` - * in every frame of 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 page, across all of its frames. Tools registered by the main frame - * come first. - * @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 page changes, for example when the - * page registers or unregisters a tool, or when a frame that registered tools 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((data: Array<{ - /** - * Tool name, unique within the frame that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => 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 be25a11af5e8b..531bd08320974 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -3929,7 +3929,6 @@ export interface PageEventTarget { on(event: 'webSocketRoute', callback: (params: PageWebSocketRouteEvent) => void): this; on(event: 'webSocket', callback: (params: PageWebSocketEvent) => void): this; on(event: 'worker', callback: (params: PageWorkerEvent) => void): this; - on(event: 'webmcpToolsChanged', callback: (params: PageWebmcpToolsChangedEvent) => void): this; } export interface PageChannel extends PageEventTarget, Channel { _type_Page: boolean; @@ -3992,10 +3991,6 @@ export interface PageChannel extends PageEventTarget, Channel { webStorageSetItem(params: PageWebStorageSetItemParams, options: TimeoutOptions): Promise; webStorageRemoveItem(params: PageWebStorageRemoveItemParams, options: TimeoutOptions): Promise; webStorageClear(params: PageWebStorageClearParams, options: TimeoutOptions): Promise; - webmcpEnable(params: PageWebmcpEnableParams, options: TimeoutOptions): Promise; - webmcpDisable(params: PageWebmcpDisableParams, options: TimeoutOptions): Promise; - webmcpTools(params: PageWebmcpToolsParams, options: TimeoutOptions): Promise; - webmcpCallTool(params: PageWebmcpCallToolParams, options: TimeoutOptions): Promise; } export type PageBindingCallEvent = { binding: BindingCallChannel, @@ -4045,19 +4040,6 @@ export type PageWebSocketEvent = { export type PageWorkerEvent = { worker: WorkerChannel, }; -export type PageWebmcpToolsChangedEvent = { - tools: { - name: string, - description: string, - inputSchema?: any, - annotations?: { - readOnly?: boolean, - untrustedContent?: boolean, - consequential?: boolean, - }, - frame: FrameChannel, - }[], -}; export type PageAddInitScriptParams = { source: string, }; @@ -4681,39 +4663,6 @@ export type PageWebStorageClearOptions = { }; export type PageWebStorageClearResult = void; -export type PageWebmcpEnableParams = {}; -export type PageWebmcpEnableOptions = {}; -export type PageWebmcpEnableResult = void; -export type PageWebmcpDisableParams = {}; -export type PageWebmcpDisableOptions = {}; -export type PageWebmcpDisableResult = void; -export type PageWebmcpToolsParams = {}; -export type PageWebmcpToolsOptions = {}; -export type PageWebmcpToolsResult = { - tools: { - name: string, - description: string, - inputSchema?: any, - annotations?: { - readOnly?: boolean, - untrustedContent?: boolean, - consequential?: boolean, - }, - frame: FrameChannel, - }[], -}; -export type PageWebmcpCallToolParams = { - name: string, - input?: any, - frame?: FrameChannel, -}; -export type PageWebmcpCallToolOptions = { - input?: any, - frame?: FrameChannel, -}; -export type PageWebmcpCallToolResult = { - result?: any, -}; export interface PageEvents { 'bindingCall': PageBindingCallEvent; @@ -4730,7 +4679,6 @@ export interface PageEvents { 'webSocketRoute': PageWebSocketRouteEvent; 'webSocket': PageWebSocketEvent; 'worker': PageWorkerEvent; - 'webmcpToolsChanged': PageWebmcpToolsChangedEvent; } // ----------- Root ----------- diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index 2956cccacd8d4..0233d000fe9cd 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -92,10 +92,6 @@ export const Events = { Worker: 'worker', }, - WebMCP: { - ToolsChanged: 'toolschanged', - }, - WebSocket: { Close: 'close', Error: 'socketerror', diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 318df4235a053..7107e15058ab3 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -39,7 +39,6 @@ import { FileChooser } from './fileChooser'; import { Frame, verifyLoadState } from './frame'; import { HarRouter } from './harRouter'; import { Keyboard, Mouse, Touchscreen } from './input'; -import { WebMCP } from './webmcp'; import { WebStorage } from './webStorage'; import { assertEvaluateOptions, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; import { Request, Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network'; @@ -110,7 +109,6 @@ 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>(); @@ -145,7 +143,6 @@ export class Page extends ChannelOwner implements api.Page this.clock = this._browserContext.clock; this.localStorage = new WebStorage(this, 'local'); this.sessionStorage = new WebStorage(this, 'session'); - this.webmcp = new WebMCP(this); this._mainFrame = Frame.from(initializer.mainFrame); this._mainFrame._page = this; diff --git a/packages/playwright-core/src/client/webmcp.ts b/packages/playwright-core/src/client/webmcp.ts deleted file mode 100644 index 6a92bb50f0757..0000000000000 --- a/packages/playwright-core/src/client/webmcp.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 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 { Frame } from './frame'; -import { kNoTimeout } from './timeoutSettings'; -import { Waiter } from './waiter'; - -import type { Page } from './page'; -import type * as api from '../../types/types'; -import type * as channels from './channels'; -import type { WaitForEventOptions } from './types'; - -export type WebMCPTool = { - name: string; - description: string; - inputSchema?: any; - annotations?: { - readOnly?: boolean; - untrustedContent?: boolean; - consequential?: boolean; - }; - frame: Frame; -}; - -export class WebMCP extends EventEmitter implements api.WebMCP { - private _page: Page; - - constructor(page: Page) { - super(); - this.setMaxListeners(0); - this._page = page; - this._page._channel.on('webmcpToolsChanged', ({ tools }) => this.emit(Events.WebMCP.ToolsChanged, tools.map(fromProtocol))); - } - - async enable(): Promise { - await this._page._channel.webmcpEnable({}, kNoTimeout); - return new DisposableStub(() => this.disable()); - } - - async disable(): Promise { - await this._page._channel.webmcpDisable({}, kNoTimeout); - } - - async tools(options: { timeout?: number } = {}): Promise { - const { tools } = await this._page._channel.webmcpTools({}, this._page._timeoutSettings.timeout(options)); - return tools.map(fromProtocol); - } - - async callTool(name: string, input?: any, options: { frame?: Frame, timeout?: number } = {}): Promise { - const { result } = await this._page._channel.webmcpCallTool({ name, input, frame: options.frame?._channel }, this._page._timeoutSettings.timeout(options)); - return result; - } - - async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { - return await this._page._wrapApiCall(async () => { - const timeoutOptions = this._page._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); - const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; - const waiter = Waiter.createForEvent(this._page, event); - waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded while waiting for event "${event}"`); - waiter.rejectOnEvent(this._page, Events.Page.Close, () => this._page._closeErrorWithReason()); - const result = await waiter.waitForEvent(this, event, predicate as any); - waiter.dispose(); - return result; - }); - } -} - -function fromProtocol(tool: channels.PageWebmcpToolsResult['tools'][number]): WebMCPTool { - return { ...tool, frame: Frame.from(tool.frame) }; -} diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index ef8ede4ded606..ed5766412d229 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -3930,7 +3930,6 @@ export interface PageEventTarget { _dispatchEvent(event: 'webSocketRoute', params?: PageWebSocketRouteEvent): void; _dispatchEvent(event: 'webSocket', params?: PageWebSocketEvent): void; _dispatchEvent(event: 'worker', params?: PageWorkerEvent): void; - _dispatchEvent(event: 'webmcpToolsChanged', params?: PageWebmcpToolsChangedEvent): void; } export interface PageChannel extends PageEventTarget, Channel { _type_Page: boolean; @@ -3993,10 +3992,6 @@ export interface PageChannel extends PageEventTarget, Channel { webStorageSetItem(params: PageWebStorageSetItemParams, progress: Progress): Promise; webStorageRemoveItem(params: PageWebStorageRemoveItemParams, progress: Progress): Promise; webStorageClear(params: PageWebStorageClearParams, progress: Progress): Promise; - webmcpEnable(params: PageWebmcpEnableParams, progress: Progress): Promise; - webmcpDisable(params: PageWebmcpDisableParams, progress: Progress): Promise; - webmcpTools(params: PageWebmcpToolsParams, progress: Progress): Promise; - webmcpCallTool(params: PageWebmcpCallToolParams, progress: Progress): Promise; } export type PageBindingCallEvent = { binding: BindingCallChannel, @@ -4046,19 +4041,6 @@ export type PageWebSocketEvent = { export type PageWorkerEvent = { worker: WorkerChannel, }; -export type PageWebmcpToolsChangedEvent = { - tools: { - name: string, - description: string, - inputSchema?: any, - annotations?: { - readOnly?: boolean, - untrustedContent?: boolean, - consequential?: boolean, - }, - frame: FrameChannel, - }[], -}; export type PageAddInitScriptParams = { source: string, }; @@ -4682,39 +4664,6 @@ export type PageWebStorageClearOptions = { }; export type PageWebStorageClearResult = void; -export type PageWebmcpEnableParams = {}; -export type PageWebmcpEnableOptions = {}; -export type PageWebmcpEnableResult = void; -export type PageWebmcpDisableParams = {}; -export type PageWebmcpDisableOptions = {}; -export type PageWebmcpDisableResult = void; -export type PageWebmcpToolsParams = {}; -export type PageWebmcpToolsOptions = {}; -export type PageWebmcpToolsResult = { - tools: { - name: string, - description: string, - inputSchema?: any, - annotations?: { - readOnly?: boolean, - untrustedContent?: boolean, - consequential?: boolean, - }, - frame: FrameChannel, - }[], -}; -export type PageWebmcpCallToolParams = { - name: string, - input?: any, - frame?: FrameChannel, -}; -export type PageWebmcpCallToolOptions = { - input?: any, - frame?: FrameChannel, -}; -export type PageWebmcpCallToolResult = { - result?: any, -}; export interface PageEvents { 'bindingCall': PageBindingCallEvent; @@ -4731,7 +4680,6 @@ export interface PageEvents { 'webSocketRoute': PageWebSocketRouteEvent; 'webSocket': PageWebSocketEvent; 'worker': PageWorkerEvent; - 'webmcpToolsChanged': PageWebmcpToolsChangedEvent; } // ----------- Root ----------- diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 9e026759a6290..e13652b98b7e1 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -18,7 +18,6 @@ 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'; @@ -46,7 +45,6 @@ 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 }; @@ -88,9 +86,6 @@ 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; @@ -166,34 +161,6 @@ 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(); @@ -489,9 +456,6 @@ 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)), ]); } @@ -533,12 +497,6 @@ 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 }) => { @@ -580,7 +538,6 @@ 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, @@ -924,40 +881,6 @@ 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) - this._page.webmcp.toolsAdded(frame, 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) - this._page.webmcp.toolsRemoved(frame, 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/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 862254597e36e..59295be3ea38e 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -52,7 +52,6 @@ import type { Progress } from '../progress'; import type { URLMatch } from '@isomorphic/urlMatch'; import type { ScreencastFrame } from '../types'; import type { ScreencastClient } from '../screencast'; -import type { WebMCPTool } from '../webmcp'; export class PageDispatcher extends Dispatcher implements channels.PageChannel { _type_Page = true; @@ -126,7 +125,6 @@ export class PageDispatcher extends Dispatcher this._onFrameAttached(frame)); this.addObjectListener(Page.Events.FrameDetached, frame => this._onFrameDetached(frame)); this.addObjectListener(Page.Events.LocatorHandlerTriggered, (uid: number) => this._dispatchEvent('locatorHandlerTriggered', { uid })); - this.addObjectListener(Page.Events.WebMCPToolsChanged, (tools: WebMCPTool[]) => this._dispatchEvent('webmcpToolsChanged', { tools: this._serializeWebMCPTools(tools) })); this.addObjectListener(Page.Events.WebSocket, webSocket => this._dispatchEvent('webSocket', { webSocket: new WebSocketDispatcher(this, webSocket) })); this.addObjectListener(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this, worker) })); // Ensure client knows about all frames. @@ -517,27 +515,6 @@ export class PageDispatcher extends Dispatcher { - await this._page.webmcp.enable(progress); - } - - async webmcpDisable(params: channels.PageWebmcpDisableParams, progress: Progress): Promise { - await this._page.webmcp.disable(progress); - } - - async webmcpTools(params: channels.PageWebmcpToolsParams, progress: Progress): Promise { - return { tools: this._serializeWebMCPTools(await this._page.webmcp.tools(progress)) }; - } - - async webmcpCallTool(params: channels.PageWebmcpCallToolParams, progress: Progress): Promise { - const frame = params.frame ? (params.frame as FrameDispatcher)._object : undefined; - return { result: await this._page.webmcp.callTool(progress, params.name, params.input, frame) }; - } - - private _serializeWebMCPTools(tools: WebMCPTool[]): channels.PageWebmcpToolsResult['tools'] { - return tools.map(tool => ({ ...tool, frame: FrameDispatcher.from(this.parentScope(), tool.frame) })); - } - async webStorageItems(params: channels.PageWebStorageItemsParams, progress: Progress): Promise { return { items: await this._page.webStorageItems(progress, params.kind) }; } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 325361a234862..314d13e1becdd 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -41,7 +41,6 @@ import * as rawBindingsControllerSource from '../generated/bindingsControllerSou import { Overlay } from './overlay'; import { NonRecoverableDOMError } from './dom'; import { Screencast } from './screencast'; -import { WebMCP } from './webmcp'; import { saveGlobalsSnapshotSource } from './javascript'; import type { Artifact } from './artifact'; @@ -56,7 +55,6 @@ import type { ImageComparatorOptions } from '@utils/comparators'; import type * as channels from './channels'; import type { BindingPayload } from '@injected/bindingsController'; import type { AriaNodeJSON, AriaSnapshotJSON } from '@isomorphic/ariaSnapshot'; -import type { WebMCPTool } from './webmcp'; export interface PageDelegate { readonly rawMouse: input.RawMouse; @@ -97,9 +95,6 @@ export interface PageDelegate { pdf?: (options: channels.PagePdfParams) => Promise; coverage?: () => any; noUtilityWorld?: () => boolean; - // Resolves to true when the browser reports tool registrations natively. - enableWebMCP?(): Promise; - callWebMCPTool?(progress: Progress, frame: frames.Frame, name: string, input: unknown): Promise; // Work around WebKit's raf issues on Windows. rafCountForStablePosition(): number; @@ -143,7 +138,6 @@ const PageEvent = { FrameDetached: 'framedetached', InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument', LocatorHandlerTriggered: 'locatorhandlertriggered', - WebMCPToolsChanged: 'webmcptoolschanged', WebSocket: 'websocket', Worker: 'worker', } as const; @@ -158,7 +152,6 @@ export type PageEventMap = { [PageEvent.FrameDetached]: [frame: frames.Frame]; [PageEvent.InternalFrameNavigatedToNewDocument]: [frame: frames.Frame]; [PageEvent.LocatorHandlerTriggered]: [uid: number]; - [PageEvent.WebMCPToolsChanged]: [tools: WebMCPTool[]]; [PageEvent.WebSocket]: [webSocket: network.WebSocket]; [PageEvent.Worker]: [worker: Worker]; }; @@ -208,7 +201,6 @@ export class Page extends SdkObject { readonly overlay: Overlay; readonly screencast: Screencast; - readonly webmcp: WebMCP; _closeReason: string | undefined; private _customCloseHandler?: (runBeforeUnload: boolean) => Promise; @@ -225,7 +217,6 @@ export class Page extends SdkObject { this.frameManager = new frames.FrameManager(this); this.overlay = new Overlay(this); this.screencast = new Screencast(this); - this.webmcp = new WebMCP(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 deleted file mode 100644 index bf776555ca1fa..0000000000000 --- a/packages/playwright-core/src/server/webmcp.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * 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 { Page } from './page'; - -import type * as frames from './frames'; -import type { InitScript, 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 WebMCPTool = WebMCPToolInfo & { - frame: frames.Frame; -}; - -export type RawWebMCPTool = { - name: string; - description: string; - inputSchema?: unknown; - annotations?: WebMCPToolAnnotations; -}; - -const kToolsChangedBinding = '__pw_webmcpToolsChanged'; -const kRegistryProperty = '__pw_webmcpRegistry'; - -export class WebMCP { - private _page: Page; - private _toolsByFrame = new Map>(); - private _enabled: Promise | undefined; - private _binding: PageBinding | undefined; - private _initScript: InitScript | undefined; - private _lastSignature: string | undefined; - private _changeScheduled = false; - - constructor(page: Page) { - this._page = page; - // Browsers do not report the tools of a document that goes away. - this._page.on(Page.Events.InternalFrameNavigatedToNewDocument, frame => this._frameGone(frame)); - this._page.on(Page.Events.FrameDetached, frame => this._frameGone(frame)); - } - - async enable(progress: Progress) { - if (!this._enabled) { - this._assertBrowserSupport(); - 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; - const { _binding: binding, _initScript: initScript } = this; - this._binding = undefined; - this._initScript = undefined; - await progress.race(Promise.all([binding?.dispose(), initScript?.dispose()])); - } - - async tools(progress: Progress): Promise { - await progress.race(this._enabledPromise()); - return this._snapshot(); - } - - async callTool(progress: Progress, name: string, input: unknown, frame: frames.Frame | undefined): Promise { - const native = await progress.race(this._enabledPromise()); - const tools = this._snapshot(); - const matches = tools.filter(tool => tool.name === name && (!frame || tool.frame === frame)); - if (!matches.length) { - const available = [...new Set(tools.map(tool => tool.name))]; - throw new Error(`No WebMCP tool named "${name}"${frame ? ` in frame ${frame.url()}` : ''}.` + - (available.length ? ` Available tools: ${available.join(', ')}.` : ' The page does not register any WebMCP tools.')); - } - if (matches.length > 1) - throw new Error(`WebMCP tool "${name}" is registered in multiple frames, pass the frame option to pick one.`); - const tool = matches[0]; - if (native) - return await this._page.delegate.callWebMCPTool!(progress, tool.frame, name, input ?? {}); - return await this._callToolInPage(progress, tool.frame, name, input ?? {}); - } - - toolsAdded(frame: frames.Frame, tools: RawWebMCPTool[]) { - if (!tools.length) - return; - let byName = this._toolsByFrame.get(frame); - if (!byName) { - byName = new Map(); - this._toolsByFrame.set(frame, byName); - } - for (const tool of tools) - byName.set(tool.name, normalizeTool(tool)); - this._scheduleChanged(); - } - - toolsRemoved(frame: frames.Frame, names: string[]) { - const byName = this._toolsByFrame.get(frame); - if (!byName) - return; - for (const name of names) - byName.delete(name); - if (!byName.size) - this._toolsByFrame.delete(frame); - this._scheduleChanged(); - } - - private _enabledPromise(): Promise { - if (!this._enabled) - throw new Error('WebMCP is not enabled. Call page.webmcp.enable() first.'); - return this._enabled; - } - - private _assertBrowserSupport() { - const { name, browserType, browserProcess, originalLaunchOptions } = this._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.'); - } - } - - private async _enable(progress: Progress): Promise { - const native = await progress.race(this._page.delegate.enableWebMCP?.() ?? Promise.resolve(false)); - if (!native) - await this._instrumentPage(progress); - this._lastSignature = this._signature(this._snapshot()); - return native; - } - - private async _instrumentPage(progress: Progress) { - this._binding = await this._page.exposeBinding(progress, kToolsChangedBinding, ({ frame }, tools: RawWebMCPTool[]) => this._toolsReported(frame, tools), true); - const source = `(${hookModelContext})(${JSON.stringify(kToolsChangedBinding)}, ${JSON.stringify(kBindingsControllerProperty)}, ${JSON.stringify(kRegistryProperty)})`; - this._initScript = await this._page.addInitScript(progress, source); - // Documents that are already loaded registered their tools before the hooks were in place, - // or while reporting was disabled. - const reported = await progress.race(Promise.all(this._page.frames().map(async frame => { - const tools: RawWebMCPTool[] | null = await frame.nonStallingEvaluateInExistingContext(source, 'main').catch(() => null); - return { frame, tools }; - }))); - this._toolsByFrame.clear(); - for (const { frame, tools } of reported) { - if (tools) - this._toolsReported(frame, tools); - } - } - - private _toolsReported(frame: frames.Frame, tools: RawWebMCPTool[]) { - if (tools.length) - this._toolsByFrame.set(frame, new Map(tools.map(tool => [tool.name, normalizeTool(tool)]))); - else - this._toolsByFrame.delete(frame); - this._scheduleChanged(); - } - - private _frameGone(frame: frames.Frame) { - if (this._toolsByFrame.delete(frame)) - this._scheduleChanged(); - } - - 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._page.emit(Page.Events.WebMCPToolsChanged, tools); - } - - private _signature(tools: WebMCPTool[]): string { - return JSON.stringify(tools.map(tool => [tool.frame.guid, tool.name, tool.description, tool.inputSchema ?? null, tool.annotations ?? null])); - } - - private _snapshot(): WebMCPTool[] { - const result: WebMCPTool[] = []; - for (const frame of this._page.frames()) { - const byName = this._toolsByFrame.get(frame); - if (!byName) - continue; - for (const tool of byName.values()) - result.push({ ...tool, frame }); - } - return result; - } - - private async _callToolInPage(progress: Progress, frame: frames.Frame, name: string, input: unknown): Promise { - const resultJson: string = await frame.evaluateExpression(progress, String(callToolInPage), { isFunction: true }, { name, inputJson: JSON.stringify(input), registryProperty: kRegistryProperty }); - return JSON.parse(resultJson); - } -} - -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 } : {}), - }; -} - -// 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; - description?: string; - inputSchema?: unknown; - annotations?: Record; - execute?: (input: unknown) => unknown; - 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 hookModelContext(bindingName: string, controllerProperty: string, registryProperty: string) { - const modelContext = (document as DocumentWithModelContext).modelContext - ?? (navigator as NavigatorWithModelContext).modelContext; - if (!modelContext) - return []; - const global = globalThis as unknown as Record; - const describe = (registry: Map) => [...registry.values()].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, - 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, - }; - }); - - const existing = global[registryProperty] as Map | undefined; - if (existing) - return describe(existing); - const registry = new Map(); - Object.defineProperty(global, registryProperty, { value: registry, configurable: true }); - - const report = () => { - const controller = global[controllerProperty] as { callBinding(name: string, ...args: unknown[]): Promise } | undefined; - // Reporting stops once the binding is disposed, the page must not notice. - try { - controller?.callBinding(bindingName, describe(registry)).catch(() => {}); - } catch { - } - }; - const prototype = Object.getPrototypeOf(modelContext); - const wrap = (method: string, update: (...args: any[]) => void) => { - const original = prototype[method]; - if (typeof original !== 'function') - return; - prototype[method] = function(this: unknown, ...args: unknown[]) { - const result = original.apply(this, args); - update(...args); - report(); - return result; - }; - }; - wrap('registerTool', (tool: PageRegisteredTool) => registry.set(tool.name, tool)); - wrap('unregisterTool', (name: string) => registry.delete(name)); - wrap('provideContext', (params?: { tools?: PageRegisteredTool[] }) => { - registry.clear(); - for (const tool of params?.tools ?? []) - registry.set(tool.name, tool); - }); - wrap('clearContext', () => registry.clear()); - - if (!modelContext.getTools) - return []; - return Promise.resolve(modelContext.getTools()).then(tools => { - for (const tool of tools) { - // Chromium's getTools() aggregates same-origin descendant frames, Firefox's does not. - if (!('window' in tool) || tool.window === window) - registry.set(tool.name, tool); - } - return describe(registry); - }); -} - -function callToolInPage(params: { name: string, inputJson: string, registryProperty: string }) { - const modelContext = (document as DocumentWithModelContext).modelContext - ?? (navigator as NavigatorWithModelContext).modelContext; - if (!modelContext) - throw new Error('WebMCP is not available on this page'); - const input = JSON.parse(params.inputJson); - const stringify = (result: unknown) => result === undefined ? 'null' : JSON.stringify(result); - // Firefox: invokeTool(name, inputObject) resolves to the value itself. - if (modelContext.invokeTool) - return Promise.resolve(modelContext.invokeTool(params.name, input)).then(stringify); - if (modelContext.executeTool && modelContext.getTools) { - // 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, input)).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)); - } - const registry = (globalThis as unknown as Record)[params.registryProperty] as Map | undefined; - const tool = registry?.get(params.name); - if (!tool?.execute) - throw new Error(`WebMCP tool "${params.name}" is not registered in this frame`); - return Promise.resolve(tool.execute(input)).then(stringify); -} diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index e5faf29c5e1e5..da326c2721a56 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -140,12 +140,6 @@ export class Tab extends EventEmitter { this._downloadStarted(download).catch(e => debug('pw:tools:error')(e)); }), ]; - if (context.config.webmcp !== false) { - // Subscribing makes the browser report registrations as they happen, so the MCP tool list follows the page. - this._disposables.push(eventsHelper.addEventListener(p.webmcp, 'toolschanged', () => { - this.updateWebMCPTools().catch(e => debug('pw:tools:error')(e)); - })); - } // eslint-disable-next-line no-restricted-syntax (page as any)[tabSymbol] = this; const wallTime = Date.now(); @@ -182,8 +176,6 @@ export class Tab extends EventEmitter { } private async _initialize() { - if (this.context.config.webmcp !== false) - await this.page.webmcp.enable().catch(e => debug('pw:tools:error')(e)); for (const message of await Tab.collectConsoleMessages(this.page)) this._handleConsoleMessage(message); const requests = await this.page.requests().catch(() => []); diff --git a/packages/playwright-core/src/tools/backend/webmcp.ts b/packages/playwright-core/src/tools/backend/webmcp.ts index de17d97af1e03..3f40295d22aac 100644 --- a/packages/playwright-core/src/tools/backend/webmcp.ts +++ b/packages/playwright-core/src/tools/backend/webmcp.ts @@ -23,10 +23,11 @@ import type * as mcpServer from '../utils/mcp/server'; import type { Tab } from './tab'; import type * as playwright from '../../..'; -const kListTimeout = 5000; +const kFrameTimeout = 5000; export type WebMCPToolInfo = { name: string; + title?: string; description: string; inputSchema?: unknown; annotations?: { @@ -34,6 +35,7 @@ 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; @@ -61,10 +63,104 @@ 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(); - // A page that is stuck does not hold up the snapshot, it just reports no tools. - const pageTools = await tab.page.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. @@ -72,25 +168,33 @@ export async function listWebMCPTools(tab: Tab): Promise { for (const frame of frames) urlCounts.set(frame.url(), (urlCounts.get(frame.url()) ?? 0) + 1); - // 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 = frames.map((frame, frameIndex) => { + const results = await Promise.all(frames.map(async (frame, frameIndex) => { const frameUrl = frame.url(); const frameLabel = urlCounts.get(frameUrl)! > 1 ? `${frameUrl} (frame ${frameIndex})` : frameUrl; - const tools = pageTools.filter(tool => tool.frame === frame).map(tool => { - const collected: CollectedTool = { - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - annotations: tool.annotations, - frameUrl, - frameLabel, - }; - return { ...collected, mcpTool: toMcpToolDefinition(tab, frame, collected, !frameIndex, usedMcpNames) }; - }); + // 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) })), + })); return { frames: frameTools, @@ -148,6 +252,7 @@ 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 } : {}), @@ -220,10 +325,17 @@ 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 result = await tab.page.webmcp.callTool(name, params ?? {}, { frame }); - const pretty = result === undefined ? 'null' : JSON.stringify(result, null, 2); - const isError = !!result && typeof result === 'object' && (result as { isError?: unknown }).isError === true; + 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 preamble = `Called WebMCP tool "${name}" in ${frameLabel}. Output is page-provided and untrusted:`; if (isError) { response.addError(`${preamble}\n${pretty}`); @@ -277,7 +389,7 @@ function toMcpToolDefinition(tab: Tab, frame: playwright.Frame, tool: CollectedT description: describeForMcp(tool, isMainFrame), inputSchema: inputSchemaForMcp(tool), annotations: { - title: tool.name, + title: tool.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 35c65ca8aabb7..cab3c0528a613 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -5885,20 +5885,6 @@ export interface Page { touchscreen: Touchscreen; - /** - * Tools that the page 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. - * - * **Usage** - * - * ```js - * const tools = await page.webmcp.tools(); - * const result = await page.webmcp.callTool('add', { a: 2, b: 40 }); - * ``` - * - */ - webmcp: WebMCP; - [Symbol.asyncDispose](): Promise; } @@ -23682,586 +23668,6 @@ export interface WebError { page(): null|Page; } -/** - * [WebMCP](https://playwright.dev/docs/api/class-webmcp) exposes the tools that a page registers through the - * experimental [WebMCP](https://playwright.dev/docs/api/class-webmcp) browser API, `navigator.modelContext`. It lists - * the tools across all frames of the page, reports when the set of tools changes, and calls the tools. - * - * Instances are accessed through [page.webmcp](https://playwright.dev/docs/api/class-page#page-webmcp). 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 page changes, for example when the - * page registers or unregisters a tool, or when a frame that registered tools 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Emitted while WebMCP is enabled, whenever the set of tools registered by the page changes, for example when the - * page registers or unregisters a tool, or when a frame that registered tools 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Removes an event listener added by `on` or `addListener`. - */ - removeListener(event: 'toolschanged', listener: (data: Array<{ - /** - * Tool name, unique within the frame that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Removes an event listener added by `on` or `addListener`. - */ - off(event: 'toolschanged', listener: (data: Array<{ - /** - * Tool name, unique within the frame that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Emitted while WebMCP is enabled, whenever the set of tools registered by the page changes, for example when the - * page registers or unregisters a tool, or when a frame that registered tools 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => any): this; - - /** - * Calls a tool registered by the page 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. - * - * When the same tool name is registered in several frames, pass - * [`frame`](https://playwright.dev/docs/api/class-webmcp#web-mcp-call-tool-option-frame) to pick one. - * - * ```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?: { - /** - * Frame that registered the tool. Required when the same tool name is registered in more than one frame. - */ - frame?: Frame; - - /** - * 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 page 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 page 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. - * - * Chromium reports tool registrations natively. Firefox does not, so Playwright instruments `navigator.modelContext` - * in every frame of 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 page, across all of its frames. Tools registered by the main frame - * come first. - * @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 page changes, for example when the - * page registers or unregisters a tool, or when a frame that registered tools 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 that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((data: Array<{ - /** - * Tool name, unique within the frame that registered it. - */ - 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; - }; - - /** - * Frame that registered the tool. - */ - frame: Frame; - }>) => 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/page.yml b/packages/protocol/spec/page.yml index e6999f9aeafe5..8c522a2549509 100644 --- a/packages/protocol/spec/page.yml +++ b/packages/protocol/spec/page.yml @@ -721,43 +721,6 @@ Page: - local - session - 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? - frame: Frame - - webmcpCallTool: - title: Call WebMCP tool - parameters: - name: string - input: json? - frame: Frame? - returns: - result: json? - events: bindingCall: @@ -822,21 +785,3 @@ Page: worker: parameters: worker: Worker - - 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? - frame: Frame diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index ce6bcfa1b7bba..0e6061c6adbd7 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -2309,19 +2309,6 @@ scheme.PageWebSocketEvent = tObject({ scheme.PageWorkerEvent = tObject({ worker: tChannel(['Worker']), }); -scheme.PageWebmcpToolsChangedEvent = tObject({ - tools: tArray(tObject({ - name: tString, - description: tString, - inputSchema: tOptional(tAny), - annotations: tOptional(tObject({ - readOnly: tOptional(tBoolean), - untrustedContent: tOptional(tBoolean), - consequential: tOptional(tBoolean), - })), - frame: tChannel(['Frame']), - })), -}); scheme.PageAddInitScriptParams = tObject({ source: tString, }); @@ -2717,32 +2704,6 @@ scheme.PageWebStorageClearParams = tObject({ kind: tEnum(['local', 'session']), }); scheme.PageWebStorageClearResult = tOptional(tObject({})); -scheme.PageWebmcpEnableParams = tOptional(tObject({})); -scheme.PageWebmcpEnableResult = tOptional(tObject({})); -scheme.PageWebmcpDisableParams = tOptional(tObject({})); -scheme.PageWebmcpDisableResult = tOptional(tObject({})); -scheme.PageWebmcpToolsParams = tOptional(tObject({})); -scheme.PageWebmcpToolsResult = tObject({ - tools: tArray(tObject({ - name: tString, - description: tString, - inputSchema: tOptional(tAny), - annotations: tOptional(tObject({ - readOnly: tOptional(tBoolean), - untrustedContent: tOptional(tBoolean), - consequential: tOptional(tBoolean), - })), - frame: tChannel(['Frame']), - })), -}); -scheme.PageWebmcpCallToolParams = tObject({ - name: tString, - input: tOptional(tAny), - frame: tOptional(tChannel(['Frame'])), -}); -scheme.PageWebmcpCallToolResult = tObject({ - result: tOptional(tAny), -}); scheme.RootInitializer = tOptional(tObject({})); scheme.RootInitializeParams = tObject({ sdkLanguage: tType('SDKLanguage'), diff --git a/tests/library/webmcp-disabled.spec.ts b/tests/library/webmcp-disabled.spec.ts deleted file mode 100644 index 94b20aaa3ae66..0000000000000 --- a/tests/library/webmcp-disabled.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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 deleted file mode 100644 index 3823ce9a39614..0000000000000 --- a/tests/library/webmcp.spec.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * 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 }, - frame: page.mainFrame(), - }, - { - name: 'noop', - description: 'Does nothing', - frame: page.mainFrame(), - }, - ]); -}); - -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 page 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', frame: page.mainFrame() })]); - 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 page.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(tools[1].frame).toBe(page.mainFrame()); - 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' } } }), - frame: page.mainFrame(), - })]); - - // 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 list tools across frames', 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]; - expect(await page.webmcp.tools()).toEqual([ - expect.objectContaining({ name: 'add', frame: page.mainFrame() }), - expect.objectContaining({ name: 'subscribe', frame: childFrame }), - ]); - expect(await page.webmcp.callTool('subscribe')).toEqual({ content: [{ type: 'text', text: 'subscribed' }] }); - expect(await page.webmcp.callTool('subscribe', {}, { frame: childFrame })).toEqual({ content: [{ type: 'text', text: 'subscribed' }] }); - - const changed = page.webmcp.waitForEvent('toolschanged'); - await page.evaluate(() => document.querySelector('iframe')!.remove()); - expect(await changed).toEqual([expect.objectContaining({ name: 'add' })]); -}); - -test('should disambiguate same-name tools by 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 error = await page.webmcp.callTool('echo').catch(e => e); - expect(error.message).toContain('registered in multiple frames'); - expect(await page.webmcp.callTool('echo', {}, { frame: page.mainFrame() })).toEqual({ content: [{ type: 'text', text: 'main' }] }); - expect(await page.webmcp.callTool('echo', {}, { frame: page.frames()[1] })).toEqual({ content: [{ type: 'text', text: 'frame' }] }); -});