From e8ec461feb656982f84baed22b039855a1d65b0c Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 27 Oct 2025 16:04:43 +0100 Subject: [PATCH 1/2] feat: get state from the open DevTools window --- src/McpContext.ts | 44 +++++++++++++++++++++++++++++++++++-- src/McpResponse.ts | 21 ++++++++++++++++++ src/PageCollector.ts | 16 ++++++++++++++ src/main.ts | 3 +++ src/tools/ToolDefinition.ts | 1 + src/tools/devtools.ts | 21 ++++++++++++++++++ 6 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 src/tools/devtools.ts diff --git a/src/McpContext.ts b/src/McpContext.ts index 586c2ba7..ceae3513 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -337,12 +337,13 @@ export class McpContext implements Context { ); }); - await this.#detectOpenDevToolsWindows(allPages); + await this.detectOpenDevToolsWindows(); return this.#pages; } - async #detectOpenDevToolsWindows(pages: Page[]) { + async detectOpenDevToolsWindows() { + const pages = await this.browser.pages(); this.#pageToDevToolsPage = new Map(); for (const devToolsPage of pages) { if (devToolsPage.url().startsWith('devtools://')) { @@ -377,6 +378,45 @@ export class McpContext implements Context { return this.#pageToDevToolsPage.get(page); } + async getDevToolsData(): Promise { + try { + const selectedPage = this.getSelectedPage(); + const devtoolsPage = this.getDevToolsPage(selectedPage); + if (devtoolsPage) { + const cdpRequestId = await devtoolsPage.evaluate(async () => { + // @ts-expect-error no types + const UI = await import('/bundled/ui/legacy/legacy.js'); + // @ts-expect-error no types + const SDK = await import('/bundled/core/sdk/sdk.js'); + const request = UI.Context.Context.instance().flavor( + SDK.NetworkRequest.NetworkRequest, + ); + return request?.requestId(); + }); + if (!cdpRequestId) { + this.logger('no context request'); + return; + } + const request = this.#networkCollector.find(selectedPage, request => { + // @ts-expect-error id is internal. + return request.id === cdpRequestId; + }); + if (!request) { + this.logger('no collected request for ' + cdpRequestId); + return; + } + return { + requestId: this.#networkCollector.getIdForResource(request), + }; + } else { + this.logger('no devtools page deteched'); + } + } catch (err) { + this.logger('error getting devtools data', err); + } + return; + } + /** * Creates a text snapshot of a page. */ diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 24f2b3f8..f0f03372 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -51,6 +51,7 @@ export class McpResponse implements Response { types?: string[]; includePreservedMessages?: boolean; }; + #includeDevtoolsData = false; setIncludePages(value: boolean): void { this.#includePages = value; @@ -62,6 +63,10 @@ export class McpResponse implements Response { }; } + includeDevtoolsData(value: boolean): void { + this.#includeDevtoolsData = value; + } + setIncludeNetworkRequests( value: boolean, options?: PaginationOptions & { @@ -291,11 +296,17 @@ export class McpResponse implements Response { ); } + let devToolsData; + if (this.#includeDevtoolsData) { + devToolsData = await context.getDevToolsData(); + } + return this.format(toolName, context, { bodies, consoleData, consoleListData, formattedSnapshot, + devToolsData, }); } @@ -310,6 +321,7 @@ export class McpResponse implements Response { consoleData: ConsoleMessageData | undefined; consoleListData: ConsoleMessageData[] | undefined; formattedSnapshot: string | undefined; + devToolsData?: {requestId?: number}; }, ): Array { const response = [`# ${toolName} response`]; @@ -317,6 +329,15 @@ export class McpResponse implements Response { response.push(line); } + response.push('## Network requests inspected in DevTools'); + if (data.devToolsData?.requestId) { + response.push(`Network request: reqid=${data.devToolsData?.requestId}`); + } else { + response.push( + `Nothing inspected right now. Call list_pages to check if anything is selected by the user in DevTools.`, + ); + } + const networkConditions = context.getNetworkConditions(); if (networkConditions) { response.push(`## Network emulation`); diff --git a/src/PageCollector.ts b/src/PageCollector.ts index 8cb43350..9403d102 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -173,6 +173,22 @@ export class PageCollector { throw new Error('Request not found for selected page'); } + + find(page: Page, filter: (item: T) => boolean): T | undefined { + const navigations = this.storage.get(page); + if (!navigations) { + return; + } + + for (const navigation of navigations) { + for (const collected of navigation) { + if (filter(collected)) { + return collected; + } + } + } + return; + } } export class NetworkCollector extends PageCollector { diff --git a/src/main.ts b/src/main.ts index 3443cecc..0d5de6ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ import { } from './third_party/index.js'; import {ToolCategory} from './tools/categories.js'; import * as consoleTools from './tools/console.js'; +import * as devtoolsTools from './tools/devtools.js'; import * as emulationTools from './tools/emulation.js'; import * as inputTools from './tools/input.js'; import * as networkTools from './tools/network.js'; @@ -129,6 +130,7 @@ function registerTool(tool: ToolDefinition): void { try { logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); const context = await getContext(); + await context.detectOpenDevToolsWindows(); const response = new McpResponse(); await tool.handler( { @@ -168,6 +170,7 @@ function registerTool(tool: ToolDefinition): void { const tools = [ ...Object.values(consoleTools), + ...Object.values(devtoolsTools), ...Object.values(emulationTools), ...Object.values(inputTools), ...Object.values(networkTools), diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 68f29260..b58fa75a 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -65,6 +65,7 @@ export interface Response { }, ): void; includeSnapshot(params?: SnapshotParams): void; + includeDevtoolsData(value: boolean): void; attachImage(value: ImageContentData): void; attachNetworkRequest(reqid: number): void; attachConsoleMessage(msgid: number): void; diff --git a/src/tools/devtools.ts b/src/tools/devtools.ts new file mode 100644 index 00000000..9bf6f063 --- /dev/null +++ b/src/tools/devtools.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; + +export const emulateNetwork = defineTool({ + name: 'list_devtools_data', + description: `Returns data (network requests) that the user is currently inspecting in DevTools.`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: {}, + handler: async (_request, response, _context) => { + response.includeDevtoolsData(true); + }, +}); From 2968d8531c11ac6dd03eb2c3b87cc208f648b036 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Tue, 28 Oct 2025 10:35:18 +0100 Subject: [PATCH 2/2] Integrate with network request --- docs/tool-reference.md | 5 +++-- src/McpResponse.ts | 26 +++++------------------ src/PageCollector.ts | 22 +++++++++---------- src/formatters/networkFormatter.ts | 3 ++- src/main.ts | 2 -- src/tools/ToolDefinition.ts | 3 ++- src/tools/devtools.ts | 21 ------------------ src/tools/network.ts | 25 +++++++++++++++++----- tests/formatters/networkFormatter.test.ts | 10 +++++++++ 9 files changed, 53 insertions(+), 64 deletions(-) delete mode 100644 src/tools/devtools.ts diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 7e51181b..b9bc6d28 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -255,11 +255,12 @@ ### `get_network_request` -**Description:** Gets a network request by URL. You can get all requests by calling [`list_network_requests`](#list_network_requests). +**Description:** Gets a network request by reqid. You can get all requests by calling [`list_network_requests`](#list_network_requests). +Get the request currently selected in the DevTools UI by ommitting reqid **Parameters:** -- **reqid** (number) **(required)**: The reqid of a request on the page from the listed network requests +- **reqid** (number) _(optional)_: The reqid of the network request. If omitted, looks up the current request selected in DevTools UI. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index f0f03372..6798c90b 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -44,6 +44,7 @@ export class McpResponse implements Response { pagination?: PaginationOptions; resourceTypes?: ResourceType[]; includePreservedRequests?: boolean; + networkRequestIdInDevToolsUI?: number; }; #consoleDataOptions?: { include: boolean; @@ -51,7 +52,6 @@ export class McpResponse implements Response { types?: string[]; includePreservedMessages?: boolean; }; - #includeDevtoolsData = false; setIncludePages(value: boolean): void { this.#includePages = value; @@ -63,15 +63,12 @@ export class McpResponse implements Response { }; } - includeDevtoolsData(value: boolean): void { - this.#includeDevtoolsData = value; - } - setIncludeNetworkRequests( value: boolean, options?: PaginationOptions & { resourceTypes?: ResourceType[]; includePreservedRequests?: boolean; + networkRequestIdInDevToolsUI?: number; }, ): void { if (!value) { @@ -90,6 +87,7 @@ export class McpResponse implements Response { : undefined, resourceTypes: options?.resourceTypes, includePreservedRequests: options?.includePreservedRequests, + networkRequestIdInDevToolsUI: options?.networkRequestIdInDevToolsUI, }; } @@ -296,17 +294,11 @@ export class McpResponse implements Response { ); } - let devToolsData; - if (this.#includeDevtoolsData) { - devToolsData = await context.getDevToolsData(); - } - return this.format(toolName, context, { bodies, consoleData, consoleListData, formattedSnapshot, - devToolsData, }); } @@ -321,7 +313,6 @@ export class McpResponse implements Response { consoleData: ConsoleMessageData | undefined; consoleListData: ConsoleMessageData[] | undefined; formattedSnapshot: string | undefined; - devToolsData?: {requestId?: number}; }, ): Array { const response = [`# ${toolName} response`]; @@ -329,15 +320,6 @@ export class McpResponse implements Response { response.push(line); } - response.push('## Network requests inspected in DevTools'); - if (data.devToolsData?.requestId) { - response.push(`Network request: reqid=${data.devToolsData?.requestId}`); - } else { - response.push( - `Nothing inspected right now. Call list_pages to check if anything is selected by the user in DevTools.`, - ); - } - const networkConditions = context.getNetworkConditions(); if (networkConditions) { response.push(`## Network emulation`); @@ -412,6 +394,8 @@ Call ${handleDialog.name} to handle it before continuing.`); getShortDescriptionForRequest( request, context.getNetworkRequestStableId(request), + context.getNetworkRequestStableId(request) === + this.#networkRequestsOptions?.networkRequestIdInDevToolsUI, ), ); } diff --git a/src/PageCollector.ts b/src/PageCollector.ts index 9403d102..22b9c7a8 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -163,28 +163,28 @@ export class PageCollector { throw new Error('No requests found for selected page'); } - for (const navigation of navigations) { - for (const collected of navigation) { - if (collected[stableIdSymbol] === stableId) { - return collected; - } - } + const item = this.find(page, item => item[stableIdSymbol] === stableId); + + if (item) { + return item; } throw new Error('Request not found for selected page'); } - find(page: Page, filter: (item: T) => boolean): T | undefined { + find( + page: Page, + filter: (item: WithSymbolId) => boolean, + ): WithSymbolId | undefined { const navigations = this.storage.get(page); if (!navigations) { return; } for (const navigation of navigations) { - for (const collected of navigation) { - if (filter(collected)) { - return collected; - } + const item = navigation.find(filter); + if (item) { + return item; } } return; diff --git a/src/formatters/networkFormatter.ts b/src/formatters/networkFormatter.ts index 3c1abe58..d3945130 100644 --- a/src/formatters/networkFormatter.ts +++ b/src/formatters/networkFormatter.ts @@ -13,9 +13,10 @@ const BODY_CONTEXT_SIZE_LIMIT = 10000; export function getShortDescriptionForRequest( request: HTTPRequest, id: number, + selectedInDevToolsUI = false, ): string { // TODO truncate the URL - return `reqid=${id} ${request.method()} ${request.url()} ${getStatusFromRequest(request)}`; + return `reqid=${id} ${request.method()} ${request.url()} ${getStatusFromRequest(request)}${selectedInDevToolsUI ? ` [selected in DevTools UI]` : ''}`; } export function getStatusFromRequest(request: HTTPRequest): string { diff --git a/src/main.ts b/src/main.ts index 0d5de6ba..9dad338e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,7 +21,6 @@ import { } from './third_party/index.js'; import {ToolCategory} from './tools/categories.js'; import * as consoleTools from './tools/console.js'; -import * as devtoolsTools from './tools/devtools.js'; import * as emulationTools from './tools/emulation.js'; import * as inputTools from './tools/input.js'; import * as networkTools from './tools/network.js'; @@ -170,7 +169,6 @@ function registerTool(tool: ToolDefinition): void { const tools = [ ...Object.values(consoleTools), - ...Object.values(devtoolsTools), ...Object.values(emulationTools), ...Object.values(inputTools), ...Object.values(networkTools), diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index b58fa75a..8df0f720 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -55,6 +55,7 @@ export interface Response { options?: PaginationOptions & { resourceTypes?: string[]; includePreservedRequests?: boolean; + networkRequestIdInDevToolsUI?: number; }, ): void; setIncludeConsoleData( @@ -65,7 +66,6 @@ export interface Response { }, ): void; includeSnapshot(params?: SnapshotParams): void; - includeDevtoolsData(value: boolean): void; attachImage(value: ImageContentData): void; attachNetworkRequest(reqid: number): void; attachConsoleMessage(msgid: number): void; @@ -103,6 +103,7 @@ export type Context = Readonly<{ text: string; timeout?: number | undefined; }): Promise; + getDevToolsData(): Promise; }>; export function defineTool( diff --git a/src/tools/devtools.ts b/src/tools/devtools.ts deleted file mode 100644 index 9bf6f063..00000000 --- a/src/tools/devtools.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; - -export const emulateNetwork = defineTool({ - name: 'list_devtools_data', - description: `Returns data (network requests) that the user is currently inspecting in DevTools.`, - annotations: { - category: ToolCategory.DEBUGGING, - readOnlyHint: true, - }, - schema: {}, - handler: async (_request, response, _context) => { - response.includeDevtoolsData(true); - }, -}); diff --git a/src/tools/network.ts b/src/tools/network.ts index fe51f72f..1df94550 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -70,19 +70,22 @@ export const listNetworkRequests = defineTool({ 'Set to true to return the preserved requests over the last 3 navigations.', ), }, - handler: async (request, response) => { + handler: async (request, response, context) => { + const data = await context.getDevToolsData(); response.setIncludeNetworkRequests(true, { pageSize: request.params.pageSize, pageIdx: request.params.pageIdx, resourceTypes: request.params.resourceTypes, includePreservedRequests: request.params.includePreservedRequests, + networkRequestIdInDevToolsUI: data?.requestId, }); }, }); export const getNetworkRequest = defineTool({ name: 'get_network_request', - description: `Gets a network request by URL. You can get all requests by calling ${listNetworkRequests.name}.`, + description: `Gets a network request by reqid. You can get all requests by calling ${listNetworkRequests.name}. +Get the request currently selected in the DevTools UI by ommitting reqid`, annotations: { category: ToolCategory.NETWORK, readOnlyHint: true, @@ -90,11 +93,23 @@ export const getNetworkRequest = defineTool({ schema: { reqid: zod .number() + .optional() .describe( - 'The reqid of a request on the page from the listed network requests', + 'The reqid of the network request. If omitted, looks up the current request selected in DevTools UI.', ), }, - handler: async (request, response, _context) => { - response.attachNetworkRequest(request.params.reqid); + handler: async (request, response, context) => { + if (request.params.reqid) { + response.attachNetworkRequest(request.params.reqid); + } else { + const data = await context.getDevToolsData(); + if (data?.requestId) { + response.attachNetworkRequest(data?.requestId); + } else { + response.appendResponseLine( + `Nothing is currently selected in DevTools UI.`, + ); + } + } }, }); diff --git a/tests/formatters/networkFormatter.test.ts b/tests/formatters/networkFormatter.test.ts index e22b0140..2bd1bbd6 100644 --- a/tests/formatters/networkFormatter.test.ts +++ b/tests/formatters/networkFormatter.test.ts @@ -71,6 +71,16 @@ describe('networkFormatter', () => { 'reqid=1 GET http://example.com [failed - Error in Network]', ); }); + + it('marks requests selected in DevTools UI', async () => { + const request = getMockRequest(); + const result = getShortDescriptionForRequest(request, 1, true); + + assert.equal( + result, + 'reqid=1 GET http://example.com [pending] [selected in DevTools UI]', + ); + }); }); describe('getFormattedHeaderValue', () => {