diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 12a0605df..1651c7728 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -12,7 +12,7 @@ import {SnapshotFormatter} from './formatters/SnapshotFormatter.js'; import type {McpContext} from './McpContext.js'; import type {McpPage} from './McpPage.js'; import {UncaughtError} from './PageCollector.js'; -import {DevTools} from './third_party/index.js'; +import {DevTools, type Protocol} from './third_party/index.js'; import type { ConsoleMessage, ImageContent, @@ -20,6 +20,7 @@ import type { ResourceType, TextContent, } from './third_party/index.js'; +import type {ToolGroup, ToolDefinition} from './tools/inPage.js'; import {handleDialog} from './tools/pages.js'; import type { DevToolsData, @@ -40,6 +41,59 @@ interface TraceInsightData { insightName: InsightName; } +async function getToolGroup( + page: McpPage, +): Promise | undefined> { + // Check if there is a `devtoolstooldiscovery` event listener + const windowHandle = await page.pptrPage.evaluateHandle(() => window); + // @ts-expect-error internal API + const client = page.pptrPage._client(); + const {listeners}: {listeners: Protocol.DOMDebugger.EventListener[]} = + await client.send('DOMDebugger.getEventListeners', { + objectId: windowHandle.remoteObject().objectId, + }); + if (listeners.find(l => l.type === 'devtoolstooldiscovery') === undefined) { + return; + } + + const toolGroup = await page.pptrPage.evaluate(() => { + return new Promise | undefined>(resolve => { + const event = new CustomEvent('devtoolstooldiscovery'); + // @ts-expect-error Adding custom property + event.respondWith = (toolGroup: ToolGroup) => { + if (!window.__dtmcp) { + window.__dtmcp = {}; + } + window.__dtmcp.toolGroup = toolGroup; + + // When receiving a toolGroup for the first time, expose a simple execution helper + if (!window.__dtmcp.executeTool) { + window.__dtmcp.executeTool = async (toolName, args) => { + if (!window.__dtmcp?.toolGroup) { + throw new Error('No tools found on the page'); + } + const tool = window.__dtmcp.toolGroup.tools.find( + t => t.name === toolName, + ); + if (!tool) { + throw new Error(`Tool ${toolName} not found`); + } + return await tool.execute(args); + }; + } + + resolve(toolGroup); + }; + window.dispatchEvent(event); + // If the page does not synchronously call `event.respondWith`, return instead of timing out + setTimeout(() => { + resolve(undefined); + }, 0); + }); + }); + return toolGroup; +} + export class McpResponse implements Response { #includePages = false; #includeExtensionServiceWorkers = false; @@ -70,6 +124,7 @@ export class McpResponse implements Response { includePreservedMessages?: boolean; }; #listExtensions?: boolean; + #listInPageTools?: boolean; #devToolsData?: DevToolsData; #tabId?: string; #args: ParsedArguments; @@ -110,6 +165,12 @@ export class McpResponse implements Response { this.#listExtensions = true; } + setListInPageTools(): void { + if (this.#args.categoryInPageTools) { + this.#listInPageTools = true; + } + } + setIncludeNetworkRequests( value: boolean, options?: PaginationOptions & { @@ -357,6 +418,12 @@ export class McpResponse implements Response { if (this.#listExtensions) { extensions = context.listExtensions(); } + + let inPageTools: ToolGroup | undefined; + if (this.#listInPageTools) { + inPageTools = await getToolGroup(context.getSelectedMcpPage()); + } + let consoleMessages: Array | undefined; if (this.#consoleDataOptions?.include) { if (!this.#page) { @@ -459,6 +526,7 @@ export class McpResponse implements Response { traceSummary: this.#attachedTraceSummary, extensions, lighthouseResult: this.#attachedLighthouseResult, + inPageTools, }); } @@ -475,6 +543,7 @@ export class McpResponse implements Response { traceInsight?: TraceInsightData; extensions?: InstalledExtension[]; lighthouseResult?: LighthouseData; + inPageTools?: ToolGroup; }, ): {content: Array; structuredContent: object} { const structuredContent: { @@ -489,6 +558,7 @@ export class McpResponse implements Response { traceInsights?: Array<{insightName: string; insightKey: string}>; lighthouseResult?: object; extensions?: object[]; + inPageTools?: object; message?: string; networkConditions?: string; navigationTimeout?: number; @@ -726,6 +796,26 @@ Call ${handleDialog.name} to handle it before continuing.`); } } + if (this.#listInPageTools) { + structuredContent.inPageTools = data.inPageTools ?? undefined; + response.push('## In-page tools'); + if (!data.inPageTools || !data.inPageTools.tools) { + response.push('No in-page tools available.'); + } else { + const toolGroup = data.inPageTools; + response.push(`${toolGroup.name}: ${toolGroup.description}`); + response.push('Available tools:'); + const toolDefinitionsMessage = toolGroup.tools + .map(tool => { + return `name="${tool.name}", description="${tool.description}", inputSchema=${JSON.stringify( + tool.inputSchema, + )}`; + }) + .join('\n'); + response.push(toolDefinitionsMessage); + } + } + if (this.#networkRequestsOptions?.include && data.networkRequests) { const requests = data.networkRequests; diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index b4792629c..80046b115 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -216,6 +216,12 @@ export const cliOptions = { describe: 'Set to true to include tools related to extensions. Note: This feature is only supported with a pipe connection. autoConnect is not supported.', }, + categoryInPageTools: { + type: 'boolean', + hidden: true, + describe: + 'Set to true to enable tools exposed by the inspected page itself', + }, performanceCrux: { type: 'boolean', default: true, diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 09f4def51..53a8eba54 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -41,7 +41,7 @@ delete startCliOptions.autoConnect; // Missing CLI serialization. delete startCliOptions.viewport; // CLI is generated based on the default tool definitions. To enable conditional -// tools, they needs to be enabled during CLI generation. +// tools, they need to be enabled during CLI generation. delete startCliOptions.experimentalPageIdRouting; delete startCliOptions.experimentalVision; delete startCliOptions.experimentalInteropTools; diff --git a/src/index.ts b/src/index.ts index 2689e34a6..362f2348a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,6 +140,12 @@ export async function createMcpServer( ) { return; } + if ( + tool.annotations.category === ToolCategory.IN_PAGE && + !serverArgs.categoryInPageTools + ) { + return; + } if ( tool.annotations.conditions?.includes('computerVision') && !serverArgs.experimentalVision diff --git a/src/third_party/index.ts b/src/third_party/index.ts index f719f4df8..0acd5ca8e 100644 --- a/src/third_party/index.ts +++ b/src/third_party/index.ts @@ -39,6 +39,7 @@ export {default as puppeteer} from 'puppeteer-core'; export type * from 'puppeteer-core'; export {PipeTransport} from 'puppeteer-core/internal/node/PipeTransport.js'; export type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js'; +export type {JSONSchema7} from 'json-schema'; export { resolveDefaultUserDataDir, detectBrowserPlatform, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 170198803..9a0ef4355 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -129,6 +129,7 @@ export interface Response { ): void; setListExtensions(): void; attachLighthouseResult(result: LighthouseData): void; + setListInPageTools(): void; } /** diff --git a/src/tools/categories.ts b/src/tools/categories.ts index 9e3512689..c8e92c17c 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -12,6 +12,7 @@ export enum ToolCategory { NETWORK = 'network', DEBUGGING = 'debugging', EXTENSIONS = 'extensions', + IN_PAGE = 'in-page', } export const labels = { @@ -22,4 +23,5 @@ export const labels = { [ToolCategory.NETWORK]: 'Network', [ToolCategory.DEBUGGING]: 'Debugging', [ToolCategory.EXTENSIONS]: 'Extensions', + [ToolCategory.IN_PAGE]: 'In-page tools', }; diff --git a/src/tools/inPage.ts b/src/tools/inPage.ts new file mode 100644 index 000000000..2e1deb00b --- /dev/null +++ b/src/tools/inPage.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {type JSONSchema7} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {definePageTool} from './ToolDefinition.js'; + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: JSONSchema7; +} + +export interface ToolGroup { + name: string; + description: string; + tools: T[]; +} + +declare global { + interface Window { + __dtmcp?: { + toolGroup?: ToolGroup< + ToolDefinition & {execute: (args: Record) => unknown} + >; + executeTool?: ( + toolName: string, + args: Record, + ) => unknown; + }; + } +} + +export const listInPageTools = definePageTool({ + name: 'list_in_page_tools', + description: `Lists all in-page-tools the page exposes for providing runtime information. + To call 'list_in_page_tools', call 'evaluate_script' with + 'window.__dtmcp.executeTool("list_in_page_tools", {})'.`, + annotations: { + category: ToolCategory.IN_PAGE, + readOnlyHint: true, + conditions: ['inPageTools'], + }, + schema: {}, + handler: async (_request, response, _context) => { + response.setListInPageTools(); + }, +}); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index d448552b0..3c74115c3 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -9,6 +9,7 @@ import type {ParsedArguments} from '../bin/chrome-devtools-mcp-cli-options.js'; import * as consoleTools from './console.js'; import * as emulationTools from './emulation.js'; import * as extensionTools from './extensions.js'; +import * as inPageTools from './inPage.js'; import * as inputTools from './input.js'; import * as lighthouseTools from './lighthouse.js'; import * as memoryTools from './memory.js'; @@ -29,6 +30,7 @@ export const createTools = (args: ParsedArguments) => { ...Object.values(consoleTools), ...Object.values(emulationTools), ...Object.values(extensionTools), + ...Object.values(inPageTools), ...Object.values(inputTools), ...Object.values(lighthouseTools), ...Object.values(memoryTools), diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index a5b2e8265..fdf8cc5ae 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -1206,6 +1206,36 @@ exports[`extensions > lists extensions 2`] = ` } `; +exports[`inPage tools > lists in-page tools 1`] = ` +## In-page tools +My Tool Group: A group of tools +Available tools: +name="myTool", description="Does something", inputSchema={"type":"object","properties":{"foo":{"type":"string"}}} +`; + +exports[`inPage tools > lists in-page tools 2`] = ` +{ + "inPageTools": { + "name": "My Tool Group", + "description": "A group of tools", + "tools": [ + { + "name": "myTool", + "description": "Does something", + "inputSchema": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + } + } + ] + } +} +`; + exports[`lighthouse > includes lighthouse report paths 1`] = ` ## Lighthouse Audit Results Mode: navigation diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 7b2964d53..dbdb9c571 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -10,6 +10,9 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {describe, it} from 'node:test'; +import sinon from 'sinon'; + +import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; import type {InsightName} from '../src/trace-processing/parse.js'; import { parseRawTraceBuffer, @@ -1013,3 +1016,79 @@ describe('lighthouse', () => { }); }); }); + +describe('inPage tools', () => { + function stubToolDiscovery(page: object) { + // @ts-expect-error Internal API + const client = page._client(); + const originalSend = client.send.bind(client); + sinon + .stub(client, 'send') + .callsFake(async (method: string, params?: Record) => { + if (method === 'DOMDebugger.getEventListeners') { + return { + listeners: [ + { + type: 'devtoolstooldiscovery', + useCapture: false, + passive: false, + once: false, + scriptId: '0', + lineNumber: 0, + columnNumber: 0, + }, + ], + }; + } + return originalSend(method, params); + }); + } + + it('lists in-page tools', async t => { + await withMcpContext( + async (response, context) => { + response.setListInPageTools(); + const emptyResult = await response.handle('test', context); + const emptyText = getTextContent(emptyResult.content[0]); + assert.ok( + emptyText.includes('No in-page tools available.'), + 'Should show message for empty in-page tools', + ); + + response.resetResponseLineForTesting(); + const mcpPage = context.getSelectedMcpPage(); + stubToolDiscovery(mcpPage.pptrPage); + sinon.stub(mcpPage.pptrPage, 'evaluate').resolves({ + name: 'My Tool Group', + description: 'A group of tools', + tools: [ + { + name: 'myTool', + description: 'Does something', + inputSchema: { + type: 'object', + properties: { + foo: {type: 'string'}, + }, + }, + }, + ], + }); + response.setListInPageTools(); + const {content, structuredContent} = await response.handle( + 'test', + context, + ); + const responseText = getTextContent(content[0]); + t.assert.snapshot?.(responseText); + assert.ok( + responseText.includes('inputSchema={"type":"object"'), + 'Response should include inputSchema', + ); + t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2)); + }, + undefined, + {categoryInPageTools: true} as ParsedArguments, + ); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 4df17bb68..f08350c08 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -115,12 +115,27 @@ describe('e2e', () => { }); }); + it('has experimental in-Page tools', async () => { + await withClient( + async client => { + const {tools} = await client.listTools(); + const listInPageTools = tools.find( + t => t.name === 'list_in_page_tools', + ); + assert.ok(listInPageTools); + }, + ['--category-in-page-tools'], + ); + }); + it('has experimental extensions tools', async () => { await withClient( async client => { const {tools} = await client.listTools(); - const clickAt = tools.find(t => t.name === 'install_extension'); - assert.ok(clickAt); + const installExtension = tools.find( + t => t.name === 'install_extension', + ); + assert.ok(installExtension); }, ['--category-extensions'], ); diff --git a/tests/tools/inPage.test.ts b/tests/tools/inPage.test.ts new file mode 100644 index 000000000..79540811f --- /dev/null +++ b/tests/tools/inPage.test.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import {listInPageTools} from '../../src/tools/inPage.js'; +import {withMcpContext} from '../utils.js'; + +describe('inPage', () => { + describe('list_in_page_tools', () => { + it('lists tools', async () => { + await withMcpContext( + async (response, context) => { + const page = await context.newPage(); + + await page.pptrPage.evaluate(() => { + window.__dtmcp = { + toolGroup: { + name: 'test-group', + description: 'test description', + tools: [ + { + name: 'test-tool', + description: 'test tool description', + inputSchema: { + type: 'object', + properties: { + arg: {type: 'string'}, + }, + }, + execute: () => 'result', + }, + ], + }, + }; + window.addEventListener('devtoolstooldiscovery', (e: Event) => { + // @ts-expect-error Event has `respondWith` + e.respondWith(window.__dtmcp?.toolGroup); + }); + }); + + await listInPageTools.handler({params: {}, page}, response, context); + + const result = await response.handle('list_in_page_tools', context); + // @ts-expect-error `structuredContent` has `inPageTools` + const actualGroup = result.structuredContent.inPageTools; + assert.strictEqual(actualGroup.name, 'test-group'); + assert.strictEqual(actualGroup.description, 'test description'); + assert.strictEqual(actualGroup.tools.length, 1); + assert.strictEqual(actualGroup.tools[0].name, 'test-tool'); + assert.strictEqual( + actualGroup.tools[0].description, + 'test tool description', + ); + assert.deepEqual(actualGroup.tools[0].inputSchema, { + type: 'object', + properties: { + arg: {type: 'string'}, + }, + }); + }, + undefined, + {categoryInPageTools: true} as ParsedArguments, + ); + }); + + it('handles empty response', async () => { + await withMcpContext( + async (response, context) => { + const page = await context.newPage(); + await page.pptrPage.evaluate(() => { + window.addEventListener('devtoolstooldiscovery', (e: Event) => { + // @ts-expect-error Event has `respondWith` + e.respondWith({}); + }); + }); + + await listInPageTools.handler({params: {}, page}, response, context); + + const result = await response.handle('list_in_page_tools', context); + assert.ok('inPageTools' in result.structuredContent); + assert.deepEqual( + (result.structuredContent as {inPageTools: undefined}).inPageTools, + {}, + ); + }, + undefined, + {categoryInPageTools: true} as ParsedArguments, + ); + }); + + it('handles no response', async () => { + await withMcpContext( + async (response, context) => { + const page = await context.newPage(); + await page.pptrPage.evaluate(() => { + window.addEventListener('devtoolstooldiscovery', () => { + // do nothing + }); + }); + + await listInPageTools.handler({params: {}, page}, response, context); + + const result = await response.handle('list_in_page_tools', context); + assert.ok('inPageTools' in result.structuredContent); + assert.strictEqual( + (result.structuredContent as {inPageTools: undefined}).inPageTools, + undefined, + ); + }, + undefined, + {categoryInPageTools: true} as ParsedArguments, + ); + }); + + it('handles no eventListener', async () => { + await withMcpContext( + async (response, context) => { + const page = await context.newPage(); + await listInPageTools.handler({params: {}, page}, response, context); + + const result = await response.handle('list_in_page_tools', context); + assert.ok('inPageTools' in result.structuredContent); + assert.strictEqual( + (result.structuredContent as {inPageTools: undefined}).inPageTools, + undefined, + ); + }, + undefined, + {categoryInPageTools: true} as ParsedArguments, + ); + }); + }); +});