diff --git a/README.md b/README.md index b63ac7376..e3da17b4d 100644 --- a/README.md +++ b/README.md @@ -487,12 +487,14 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`press_key`](docs/tool-reference.md#press_key) - [`type_text`](docs/tool-reference.md#type_text) - [`upload_file`](docs/tool-reference.md#upload_file) -- **Navigation automation** (6 tools) +- **Navigation automation** (8 tools) - [`close_page`](docs/tool-reference.md#close_page) - [`list_pages`](docs/tool-reference.md#list_pages) + - [`list_unique_pages`](docs/tool-reference.md#list_unique_pages) - [`navigate_page`](docs/tool-reference.md#navigate_page) - [`new_page`](docs/tool-reference.md#new_page) - [`select_page`](docs/tool-reference.md#select_page) + - [`select_unique_page`](docs/tool-reference.md#select_unique_page) - [`wait_for`](docs/tool-reference.md#wait_for) - **Emulation** (2 tools) - [`emulate`](docs/tool-reference.md#emulate) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 74fa725fa..c78047eb9 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -1,6 +1,6 @@ -# Chrome DevTools MCP Tool Reference (~7005 cl100k_base tokens) +# Chrome DevTools MCP Tool Reference (~7304 cl100k_base tokens) - **[Input automation](#input-automation)** (9 tools) - [`click`](#click) @@ -12,12 +12,14 @@ - [`press_key`](#press_key) - [`type_text`](#type_text) - [`upload_file`](#upload_file) -- **[Navigation automation](#navigation-automation)** (6 tools) +- **[Navigation automation](#navigation-automation)** (8 tools) - [`close_page`](#close_page) - [`list_pages`](#list_pages) + - [`list_unique_pages`](#list_unique_pages) - [`navigate_page`](#navigate_page) - [`new_page`](#new_page) - [`select_page`](#select_page) + - [`select_unique_page`](#select_unique_page) - [`wait_for`](#wait_for) - **[Emulation](#emulation)** (2 tools) - [`emulate`](#emulate) @@ -164,6 +166,14 @@ --- +### `list_unique_pages` + +**Description:** Get a list of pages open in the browser enriched with Chrome tabId identity from the tab-ID extension when available. + +**Parameters:** None + +--- + ### `navigate_page` **Description:** Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise. @@ -203,6 +213,17 @@ --- +### `select_unique_page` + +**Description:** Select a page using its Chrome tabId as reported by the tab-ID extension. + +**Parameters:** + +- **tabId** (number) **(required)**: The Chrome tabId to select. Call [`list_unique_pages`](#list_unique_pages) to find available tab IDs. +- **bringToFront** (boolean) _(optional)_: Whether to focus the page and bring it to the top. + +--- + ### `wait_for` **Description:** Wait for the specified text to appear on the selected page. diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 0d5f926ec..7284a983d 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -33,6 +33,7 @@ import type { LighthouseData, Response, SnapshotParams, + UniquePageData, } from './tools/ToolDefinition.js'; import type {InsightName, TraceResult} from './trace-processing/parse.js'; import {getInsightOutput, getTraceSummary} from './trace-processing/parse.js'; @@ -197,6 +198,7 @@ export class McpResponse implements Response { #listWebMcpTools?: boolean; #devToolsData?: DevToolsData; #tabId?: string; + #uniquePages?: UniquePageData[]; #args: ParsedArguments; #page?: McpPage; #redactNetworkHeaders = true; @@ -221,6 +223,10 @@ export class McpResponse implements Response { this.#tabId = tabId; } + setUniquePages(uniquePages: UniquePageData[]): void { + this.#uniquePages = uniquePages; + } + setIncludePages(value: boolean): void { this.#includePages = value; @@ -698,6 +704,7 @@ export class McpResponse implements Response { defaultValue?: string; }; pages?: object[]; + uniquePages?: object[]; pagination?: object; heapSnapshot?: { stats?: object; @@ -838,6 +845,30 @@ Call ${handleDialog.name} to handle it before continuing.`); structuredContent.tabId = this.#tabId; } + if (this.#uniquePages) { + if (this.#uniquePages.length) { + response.push('## Unique Pages'); + for (const page of this.#uniquePages) { + const parts = [ + `${page.pageId}:`, + `status=${page.identityStatus}`, + `tabId=${page.tabId ?? 'null'}`, + page.url, + ]; + if (page.selected) { + parts.push('[selected]'); + } + if (page.error) { + parts.push(`error="${page.error}"`); + } + response.push(parts.join(' ')); + } + } else { + response.push('No pages found.'); + } + structuredContent.uniquePages = this.#uniquePages; + } + if (data.traceSummary) { const summary = getTraceSummary(data.traceSummary); response.push(summary); diff --git a/src/bin/cliDefinitions.ts b/src/bin/cliDefinitions.ts index bf5a77420..644bae4fe 100644 --- a/src/bin/cliDefinitions.ts +++ b/src/bin/cliDefinitions.ts @@ -380,6 +380,12 @@ export const commands: Commands = { category: 'Navigation automation', args: {}, }, + list_unique_pages: { + description: + 'Get a list of pages open in the browser enriched with Chrome tabId identity from the tab-ID extension when available.', + category: 'Navigation automation', + args: {}, + }, navigate_page: { description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.', @@ -587,6 +593,26 @@ export const commands: Commands = { }, }, }, + select_unique_page: { + description: + 'Select a page using its Chrome tabId as reported by the tab-ID extension.', + category: 'Navigation automation', + args: { + tabId: { + name: 'tabId', + type: 'number', + description: + 'The Chrome tabId to select. Call list_unique_pages to find available tab IDs.', + required: true, + }, + bringToFront: { + name: 'bringToFront', + type: 'boolean', + description: 'Whether to focus the page and bring it to the top.', + required: false, + }, + }, + }, take_memory_snapshot: { description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.', diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 14d8c13bf..b8046a698 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -586,5 +586,22 @@ "argType": "number" } ] + }, + { + "name": "list_unique_pages", + "args": [] + }, + { + "name": "select_unique_page", + "args": [ + { + "name": "tab_id", + "argType": "number" + }, + { + "name": "bring_to_front", + "argType": "boolean" + } + ] } ] diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 1c42915d5..6bb0a23c9 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -99,6 +99,27 @@ export interface DevToolsData { cdpBackendNodeId?: number; } +export type UniquePageIdentityStatus = + | 'resolved' + | 'extension_unavailable' + | 'unsupported_page' + | 'script_failed' + | 'no_tab_context'; + +export interface UniquePageData { + pageId: number; + tabId: number | null; + selected: boolean; + url: string; + title: string; + identityStatus: UniquePageIdentityStatus; + uid?: string; + windowId?: number; + tabIndex?: number; + tabNumber?: number; + error?: string; +} + export interface Response { appendResponseLine(value: string): void; setHeapSnapshotAggregates( @@ -138,6 +159,7 @@ export interface Response { // Allows re-using DevTools data queried by some tools. attachDevToolsData(data: DevToolsData): void; setTabId(tabId: string): void; + setUniquePages(uniquePages: UniquePageData[]): void; attachTraceSummary(trace: TraceResult): void; attachTraceInsight( trace: TraceResult, @@ -222,6 +244,11 @@ export type Context = Readonly<{ listExtensions(): Promise>; getExtension(id: string): Promise; getSelectedMcpPage(): McpPage; + createPagesSnapshot(): Promise; + getPages(): Page[]; + getPageId(page: Page): number | undefined; + isPageSelected(page: Page): boolean; + getIsolatedContextName(page: Page): string | undefined; getExtensionServiceWorkers(): ExtensionServiceWorker[]; getExtensionServiceWorkerId( extensionServiceWorker: ExtensionServiceWorker, diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 401559348..e30c030dc 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -5,7 +5,7 @@ */ import {logger} from '../logger.js'; -import type {CdpPage, Dialog} from '../third_party/index.js'; +import type {CdpPage, Dialog, Page} from '../third_party/index.js'; import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; @@ -15,6 +15,263 @@ import { defineTool, timeoutSchema, } from './ToolDefinition.js'; +import type { + Context, + UniquePageData, + UniquePageIdentityStatus, +} from './ToolDefinition.js'; + +const TAB_ID_EXTENSION_NAME = "What's My Tab ID"; +const UNIQUE_PAGE_TIMEOUT_MS = 1_000; + +interface TabIdentityPayload { + tabId: number; + uid?: string; + windowId?: number; + tabIndex?: number; + tabNumber?: number; +} + +type UniquePageProbeResult = + | ({identityStatus: 'resolved'} & TabIdentityPayload) + | { + identityStatus: Exclude; + error: string; + }; + +async function resolveTabIdExtensionId( + context: Context, +): Promise { + try { + const extensions = await context.listExtensions(); + return Array.from(extensions.values()).find( + extension => extension.name === TAB_ID_EXTENSION_NAME && extension.enabled, + )?.id; + } catch { + return; + } +} + +function getIdentityStatusForUnsupportedTransport( + url: string, +): UniquePageIdentityStatus { + const protocol = new URL(url).protocol; + if ( + protocol === 'chrome:' || + protocol === 'devtools:' || + protocol === 'chrome-extension:' || + protocol === 'edge:' || + protocol === 'about:' + ) { + return 'unsupported_page'; + } + return 'extension_unavailable'; +} + +async function resolveUniquePageData( + page: Page, + context: Context, + extensionId?: string, +): Promise { + const pageId = context.getPageId(page); + if (pageId === undefined) { + throw new Error('Page ID not found for page.'); + } + + const baseData: UniquePageData = { + pageId, + tabId: null, + selected: context.isPageSelected(page), + url: page.url(), + title: await page.title().catch(() => ''), + identityStatus: 'extension_unavailable', + }; + + if (!extensionId) { + return { + ...baseData, + identityStatus: getIdentityStatusForUnsupportedTransport(baseData.url), + error: `Extension "${TAB_ID_EXTENSION_NAME}" is not installed or enabled.`, + }; + } + + try { + const probeResult = (await page.evaluate( + async ({installedExtensionId, timeoutMs}) => { + const protocol = location.protocol; + const unsupportedProtocols = new Set([ + 'chrome:', + 'devtools:', + 'chrome-extension:', + 'edge:', + 'about:', + ]); + + const fallbackStatus: 'unsupported_page' | 'extension_unavailable' = + unsupportedProtocols.has(protocol) + ? 'unsupported_page' + : 'extension_unavailable'; + + const runtime = ( + globalThis as typeof globalThis & { + chrome?: { + runtime?: { + lastError?: {message?: string}; + sendMessage?: ( + extensionId: string, + message: {type: string}, + callback: (response: unknown) => void, + ) => void; + }; + }; + } + ).chrome?.runtime; + + const sendMessage = runtime?.sendMessage; + + if (typeof sendMessage !== 'function') { + return { + identityStatus: fallbackStatus, + error: 'chrome.runtime.sendMessage is not available on this page.', + }; + } + + return await new Promise(resolve => { + let settled = false; + const finish = (value: UniquePageProbeResult) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(value); + }; + + const timer = setTimeout(() => { + finish({ + identityStatus: 'extension_unavailable', + error: 'Extension request timed out.', + }); + }, timeoutMs); + + try { + sendMessage( + installedExtensionId, + {type: 'GET_CURRENT_TAB_INFO'}, + (response: unknown) => { + const typedResponse = + typeof response === 'object' && response !== null + ? (response as { + ok?: boolean; + tabId?: number; + uid?: string; + windowId?: number; + tabIndex?: number; + tabNumber?: number; + error?: string; + }) + : {}; + const lastError = runtime?.lastError?.message ?? null; + if (lastError) { + finish({ + identityStatus: 'extension_unavailable', + error: lastError, + }); + return; + } + if (typedResponse.ok && Number.isInteger(typedResponse.tabId)) { + const resolvedTabId = typedResponse.tabId as number; + finish({ + identityStatus: 'resolved', + tabId: resolvedTabId, + uid: + typeof typedResponse.uid === 'string' + ? typedResponse.uid + : undefined, + windowId: + typeof typedResponse.windowId === 'number' + ? typedResponse.windowId + : undefined, + tabIndex: + typeof typedResponse.tabIndex === 'number' + ? typedResponse.tabIndex + : undefined, + tabNumber: + typeof typedResponse.tabNumber === 'number' + ? typedResponse.tabNumber + : undefined, + }); + return; + } + if ( + typeof typedResponse.error === 'string' && + typedResponse.error.includes('Current tab is unavailable') + ) { + finish({ + identityStatus: 'no_tab_context', + error: typedResponse.error, + }); + return; + } + finish({ + identityStatus: 'script_failed', + error: + typeof typedResponse.error === 'string' + ? typedResponse.error + : 'The extension returned an unexpected response.', + }); + }, + ); + } catch (error) { + finish({ + identityStatus: fallbackStatus, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + }, + { + installedExtensionId: extensionId, + timeoutMs: UNIQUE_PAGE_TIMEOUT_MS, + }, + )) as UniquePageProbeResult; + + if (probeResult.identityStatus === 'resolved') { + return { + ...baseData, + identityStatus: 'resolved', + tabId: probeResult.tabId, + uid: probeResult.uid, + windowId: probeResult.windowId, + tabIndex: probeResult.tabIndex, + tabNumber: probeResult.tabNumber, + }; + } + + return { + ...baseData, + identityStatus: probeResult.identityStatus, + error: probeResult.error, + }; + } catch (error) { + return { + ...baseData, + identityStatus: 'script_failed', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function listUniquePageData(context: Context): Promise { + await context.createPagesSnapshot(); + const extensionId = await resolveTabIdExtensionId(context); + const pages = context.getPages(); + return Promise.all( + pages.map(page => { + return resolveUniquePageData(page, context, extensionId); + }), + ); +} export const listPages = defineTool(args => { return { @@ -33,6 +290,21 @@ export const listPages = defineTool(args => { }; }); +export const listUniquePages = defineTool({ + name: 'list_unique_pages', + description: + 'Get a list of pages open in the browser enriched with Chrome tabId identity from the tab-ID extension when available.', + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: true, + }, + schema: {}, + handler: async (_request, response, context) => { + const uniquePages = await listUniquePageData(context); + response.setUniquePages(uniquePages); + }, +}); + export const selectPage = defineTool({ name: 'select_page', description: `Select a page as a context for future tool calls.`, @@ -63,6 +335,53 @@ export const selectPage = defineTool({ }, }); +export const selectUniquePage = defineTool({ + name: 'select_unique_page', + description: + 'Select a page using its Chrome tabId as reported by the tab-ID extension.', + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: true, + }, + schema: { + tabId: zod + .number() + .describe( + `The Chrome tabId to select. Call ${listUniquePages.name} to find available tab IDs.`, + ), + bringToFront: zod + .boolean() + .optional() + .describe('Whether to focus the page and bring it to the top.'), + }, + handler: async (request, response, context) => { + const uniquePages = await listUniquePageData(context); + const match = uniquePages.find(page => page.tabId === request.params.tabId); + if (!match) { + throw new Error( + `No page found for tabId ${request.params.tabId}. Call ${listUniquePages.name} to inspect current identity status.`, + ); + } + if (match.identityStatus !== 'resolved') { + throw new Error( + `Page for tabId ${request.params.tabId} is unresolved: ${match.identityStatus}.`, + ); + } + + const page = context.getPageById(match.pageId); + context.selectPage(page); + response.appendResponseLine( + `Selected page ${match.pageId} for tabId ${request.params.tabId}.`, + ); + response.setIncludePages(true); + response.setListInPageTools(); + response.setListWebMcpTools(); + if (request.params.bringToFront) { + await page.pptrPage.bringToFront(); + } + }, +}); + export const closePage = defineTool({ name: 'close_page', description: `Closes the page by its index. The last open page cannot be closed.`, diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 1617f9f6b..2b8d25477 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -14,9 +14,11 @@ import sinon from 'sinon'; import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; import { listPages, + listUniquePages, newPage, closePage, selectPage, + selectUniquePage, navigatePage, resizePage, handleDialog, @@ -206,6 +208,100 @@ describe('pages', () => { ); }); }); + + describe('list_unique_pages', () => { + it('returns extension-backed tab identity when available', async () => { + await withMcpContext(async (response, context) => { + sinon.stub(context, 'listExtensions').resolves( + new Map([ + [ + 'test-extension-id', + { + id: 'test-extension-id', + name: "What's My Tab ID", + version: '0.1.0', + enabled: true, + }, + ], + ]) as never, + ); + + const page1 = context.getSelectedPptrPage(); + await page1.setContent(html` + +

Page One

+ `); + + const page2 = await context.newPage(); + await page2.pptrPage.setContent('

Page Two

'); + + await listUniquePages.handler({params: {}}, response, context); + const result = await response.handle('list_unique_pages', context); + const uniquePages = ( + result.structuredContent as { + uniquePages: Array>; + } + ).uniquePages; + + assert.strictEqual(uniquePages.length, 2); + assert.deepStrictEqual(uniquePages[0], { + pageId: 1, + tabId: 101, + selected: false, + url: 'about:blank', + title: 'My test page', + identityStatus: 'resolved', + uid: 'uid-101', + windowId: 7, + tabIndex: 0, + tabNumber: 1, + }); + assert.strictEqual(uniquePages[1].pageId, 2); + assert.strictEqual(uniquePages[1].tabId, null); + assert.strictEqual( + uniquePages[1].identityStatus, + 'unsupported_page', + ); + }); + }); + + it('returns unresolved pages when the extension is unavailable', async () => { + await withMcpContext(async (response, context) => { + sinon.stub(context, 'listExtensions').resolves(new Map()); + + await listUniquePages.handler({params: {}}, response, context); + const result = await response.handle('list_unique_pages', context); + const uniquePages = ( + result.structuredContent as { + uniquePages: Array>; + } + ).uniquePages; + + assert.strictEqual(uniquePages.length, 1); + assert.strictEqual(uniquePages[0].tabId, null); + assert.strictEqual( + uniquePages[0].identityStatus, + 'unsupported_page', + ); + }); + }); + }); describe('new_page', () => { it('create a page', async () => { await withMcpContext(async (response, context) => { @@ -1122,4 +1218,64 @@ describe('pages', () => { }); }); }); + + describe('select_unique_page', () => { + it('selects a page by extension-backed tabId', async () => { + await withMcpContext(async (response, context) => { + sinon.stub(context, 'listExtensions').resolves( + new Map([ + [ + 'test-extension-id', + { + id: 'test-extension-id', + name: "What's My Tab ID", + version: '0.1.0', + enabled: true, + }, + ], + ]) as never, + ); + + const page1 = context.getSelectedPptrPage(); + await page1.setContent(html` + + `); + + const page2 = await context.newPage(); + await page2.pptrPage.setContent(html` + + `); + + await selectUniquePage.handler( + {params: {tabId: 101}}, + response, + context, + ); + + assert.strictEqual( + context.getPageById(1), + context.getSelectedMcpPage(), + ); + assert.ok(response.includePages); + }); + }); + }); });