Skip to content

Commit 62c1955

Browse files
authored
Merge branch 'main' into feat/waitforeventsafteraction-to-mcppage
2 parents 9b8830b + 73e1e24 commit 62c1955

13 files changed

Lines changed: 539 additions & 9 deletions

File tree

docs/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The CLI acts as a client to a background `chrome-devtools-mcp` daemon (uses Unix
1717

1818
- **Automatic Start**: The first time you call a tool (e.g., `list_pages`), the CLI automatically starts the MCP server and the browser in the background if they aren't already running.
1919
- **Persistence**: The same background instance is reused for subsequent commands, preserving the browser state (open pages, cookies, etc.).
20-
- **Manual Control**: You can explicitly manage the background process using `start`, `stop`, and `status`. The `start` command forwards all subsequent arguments to the underlying MCP server (e.g., `--headless`, `--userDataDir`) but not all args are supported. Run `chrome-devtools start --help` for supported args. Headless and isolated are enabled by default.
20+
- **Manual Control**: You can explicitly manage the background process using `start`, `stop`, and `status`. The `start` command forwards all subsequent arguments to the underlying MCP server (e.g., `--headless`, `--userDataDir`) but not all args are supported. Run `chrome-devtools start --help` for supported args. Headless is enabled by default. Isolated is enabled by default unless `--userDataDir` is provided.
2121

2222
```sh
2323
# Check if the daemon is running

src/McpContext.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
} from './third_party/index.js';
3232
import {Locator} from './third_party/index.js';
3333
import {PredefinedNetworkConditions} from './third_party/index.js';
34+
import type {ToolGroup, ToolDefinition} from './tools/inPage.js';
3435
import {listPages} from './tools/pages.js';
3536
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
3637
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
@@ -84,6 +85,7 @@ export class McpContext implements Context {
8485
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
8586
null;
8687

88+
#inPageTools?: ToolGroup<ToolDefinition>;
8789
#nextPageId = 1;
8890
#extensionPages = new WeakMap<Target, Page>();
8991

@@ -447,6 +449,14 @@ export class McpContext implements Context {
447449
this.#updateSelectedPageTimeouts();
448450
}
449451

452+
setInPageTools(toolGroup?: ToolGroup<ToolDefinition>) {
453+
this.#inPageTools = toolGroup;
454+
}
455+
456+
getInPageTools(): ToolGroup<ToolDefinition> | undefined {
457+
return this.#inPageTools;
458+
}
459+
450460
#updateSelectedPageTimeouts() {
451461
const page = this.#getSelectedMcpPage();
452462
// For waiters 5sec timeout should be sufficient.

src/McpResponse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ export class McpResponse implements Response {
422422
let inPageTools: ToolGroup<ToolDefinition> | undefined;
423423
if (this.#listInPageTools) {
424424
inPageTools = await getToolGroup(context.getSelectedMcpPage());
425+
context.setInPageTools(inPageTools);
425426
}
426427

427428
let consoleMessages: Array<ConsoleFormatter | IssueFormatter> | undefined;

src/bin/chrome-devtools.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ if (!('default' in cliOptions.headless)) {
5959
throw new Error('headless cli option unexpectedly does not have a default');
6060
}
6161
if ('default' in cliOptions.isolated) {
62-
throw new Error('headless cli option unexpectedly does not have a default');
62+
throw new Error('isolated cli option unexpectedly has a default');
6363
}
6464
startCliOptions.headless!.default = true;
65+
startCliOptions.isolated!.description =
66+
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.';
6567

6668
const y = yargs(hideBin(process.argv))
6769
.scriptName('chrome-devtools')
@@ -92,7 +94,7 @@ y.command(
9294
await stopDaemon();
9395
}
9496
// Defaults but we do not want to affect the yargs conflict resolution.
95-
if (argv.isolated === undefined) {
97+
if (argv.isolated === undefined && argv.userDataDir === undefined) {
9698
argv.isolated = true;
9799
}
98100
if (argv.headless === undefined) {

src/telemetry/ClearcutLogger.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import process from 'node:process';
88

99
import {DAEMON_CLIENT_NAME} from '../daemon/utils.js';
1010
import {logger} from '../logger.js';
11+
import type {zod, ShapeOutput} from '../third_party/index.js';
1112

1213
import type {LocalState, Persistence} from './persistence.js';
1314
import {FilePersistence} from './persistence.js';
@@ -20,6 +21,108 @@ import {
2021
import {WatchdogClient} from './WatchdogClient.js';
2122

2223
const MS_PER_DAY = 24 * 60 * 60 * 1000;
24+
const PARAM_BLOCKLIST = new Set(['uid']);
25+
26+
const SUPPORTED_ZOD_TYPES = [
27+
'ZodString',
28+
'ZodNumber',
29+
'ZodBoolean',
30+
'ZodArray',
31+
'ZodEnum',
32+
] as const;
33+
type ZodType = (typeof SUPPORTED_ZOD_TYPES)[number];
34+
35+
function isZodType(type: string): type is ZodType {
36+
return SUPPORTED_ZOD_TYPES.includes(type as ZodType);
37+
}
38+
39+
function getZodType(zodType: zod.ZodTypeAny): ZodType {
40+
const def = zodType._def;
41+
const typeName = def.typeName;
42+
43+
if (
44+
typeName === 'ZodOptional' ||
45+
typeName === 'ZodDefault' ||
46+
typeName === 'ZodNullable'
47+
) {
48+
return getZodType(def.innerType);
49+
}
50+
if (typeName === 'ZodEffects') {
51+
return getZodType(def.schema);
52+
}
53+
54+
if (isZodType(typeName)) {
55+
return typeName;
56+
}
57+
throw new Error(`Unsupported zod type for tool parameter: ${typeName}`);
58+
}
59+
60+
type LoggedToolCallArgValue = string | number | boolean;
61+
62+
function transformName(zodType: ZodType, name: string): string {
63+
if (zodType === 'ZodString') {
64+
return `${name}_length`;
65+
} else if (zodType === 'ZodArray') {
66+
return `${name}_count`;
67+
} else {
68+
return name;
69+
}
70+
}
71+
72+
function transformValue(
73+
zodType: ZodType,
74+
value: unknown,
75+
): LoggedToolCallArgValue {
76+
if (zodType === 'ZodString') {
77+
return (value as string).length;
78+
} else if (zodType === 'ZodArray') {
79+
return (value as unknown[]).length;
80+
} else {
81+
return value as LoggedToolCallArgValue;
82+
}
83+
}
84+
85+
function hasEquivalentType(zodType: ZodType, value: unknown): boolean {
86+
if (zodType === 'ZodString') {
87+
return typeof value === 'string';
88+
} else if (zodType === 'ZodArray') {
89+
return Array.isArray(value);
90+
} else if (zodType === 'ZodNumber') {
91+
return typeof value === 'number';
92+
} else if (zodType === 'ZodBoolean') {
93+
return typeof value === 'boolean';
94+
} else if (zodType === 'ZodEnum') {
95+
return (
96+
typeof value === 'string' ||
97+
typeof value === 'number' ||
98+
typeof value === 'boolean'
99+
);
100+
} else {
101+
return false;
102+
}
103+
}
104+
105+
export function sanitizeParams(
106+
params: ShapeOutput<zod.ZodRawShape>,
107+
schema: zod.ZodRawShape,
108+
): ShapeOutput<zod.ZodRawShape> {
109+
const transformed: ShapeOutput<zod.ZodRawShape> = {};
110+
for (const [name, value] of Object.entries(params)) {
111+
if (PARAM_BLOCKLIST.has(name)) {
112+
continue;
113+
}
114+
const zodType = getZodType(schema[name]);
115+
if (!hasEquivalentType(zodType, value)) {
116+
throw new Error(
117+
`parameter ${name} has type ${zodType} but value ${value} is not of equivalent type`,
118+
);
119+
}
120+
const transformedName = transformName(zodType, name);
121+
const transformedValue = transformValue(zodType, value);
122+
transformed[transformedName] = transformedValue;
123+
}
124+
return transformed;
125+
}
23126

24127
function detectOsType(): OsType {
25128
switch (process.platform) {

src/third_party/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {hideBin} from 'yargs/helpers';
1919
export {default as debug} from 'debug';
2020
export type {Debugger} from 'debug';
2121
export {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
22+
export {type ShapeOutput} from '@modelcontextprotocol/sdk/server/zod-compat.js';
2223
export {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
2324
export {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
2425
export {Client} from '@modelcontextprotocol/sdk/client/index.js';
@@ -29,6 +30,7 @@ export {
2930
type TextContent,
3031
} from '@modelcontextprotocol/sdk/types.js';
3132
export {z as zod} from 'zod';
33+
export {default as ajv} from 'ajv';
3234
export {
3335
Locator,
3436
PredefinedNetworkConditions,

src/tools/ToolDefinition.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import type {InstalledExtension} from '../utils/ExtensionRegistry.js';
2424
import type {PaginationOptions} from '../utils/types.js';
2525

2626
import type {ToolCategory} from './categories.js';
27+
import type {
28+
ToolGroup,
29+
ToolDefinition as InPageToolDefinition,
30+
} from './inPage.js';
2731

2832
export interface BaseToolDefinition<
2933
Schema extends zod.ZodRawShape = zod.ZodRawShape,
@@ -190,6 +194,7 @@ export type Context = Readonly<{
190194
triggerExtensionAction(id: string): Promise<void>;
191195
listExtensions(): InstalledExtension[];
192196
getExtension(id: string): InstalledExtension | undefined;
197+
getInPageTools(): ToolGroup<InPageToolDefinition> | undefined;
193198
getSelectedMcpPage(): McpPage;
194199
getExtensionServiceWorkers(): ExtensionServiceWorker[];
195200
getExtensionServiceWorkerId(

src/tools/inPage.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import {type JSONSchema7} from '../third_party/index.js';
7+
import {zod, ajv, type JSONSchema7} from '../third_party/index.js';
88

99
import {ToolCategory} from './categories.js';
1010
import {definePageTool} from './ToolDefinition.js';
@@ -37,9 +37,13 @@ declare global {
3737

3838
export const listInPageTools = definePageTool({
3939
name: 'list_in_page_tools',
40-
description: `Lists all in-page-tools the page exposes for providing runtime information.
41-
To call 'list_in_page_tools', call 'evaluate_script' with
42-
'window.__dtmcp.executeTool("list_in_page_tools", {})'.`,
40+
description: `Lists all in-page tools the page exposes for providing runtime information.
41+
In-page tools can be called via the 'execute_in_page_tool()' MCP tool.
42+
Alternatively, in-page tools can be executed by calling 'evaluate_script' and adding the
43+
following command to the script:
44+
'window.__dtmcp.executeTool(toolName, params)'
45+
This might be helpful when the in-page-tools return non-serializable values or when composing
46+
the in-page-tools with additional functionality.`,
4347
annotations: {
4448
category: ToolCategory.IN_PAGE,
4549
readOnlyHint: true,
@@ -50,3 +54,68 @@ export const listInPageTools = definePageTool({
5054
response.setListInPageTools();
5155
},
5256
});
57+
58+
export const executeInPageTool = definePageTool({
59+
name: 'execute_in_page_tool',
60+
description: `Executes a tool exposed by the page.`,
61+
annotations: {
62+
category: ToolCategory.IN_PAGE,
63+
readOnlyHint: false,
64+
conditions: ['inPageTools'],
65+
},
66+
schema: {
67+
toolName: zod.string().describe('The name of the tool to execute'),
68+
params: zod
69+
.string()
70+
.optional()
71+
.describe('The JSON-stringified parameters to pass to the tool'),
72+
},
73+
handler: async (request, response, context) => {
74+
const page = context.getSelectedMcpPage();
75+
const toolName = request.params.toolName;
76+
let params: Record<string, unknown> = {};
77+
if (request.params.params) {
78+
try {
79+
const parsed = JSON.parse(request.params.params);
80+
if (typeof parsed === 'object' && parsed !== null) {
81+
params = parsed;
82+
} else {
83+
throw new Error('Parsed params is not an object');
84+
}
85+
} catch (e) {
86+
const errorMessage = e instanceof Error ? e.message : String(e);
87+
throw new Error(`Failed to parse params as JSON: ${errorMessage}`);
88+
}
89+
}
90+
91+
const toolGroup = context.getInPageTools();
92+
const tool = toolGroup?.tools.find(t => t.name === toolName);
93+
if (!tool) {
94+
throw new Error(`Tool ${toolName} not found`);
95+
}
96+
const ajvInstance = new ajv();
97+
const validate = ajvInstance.compile(tool.inputSchema);
98+
const valid = validate(params);
99+
if (!valid) {
100+
throw new Error(
101+
`Invalid parameters for tool ${toolName}: ${ajvInstance.errorsText(validate.errors)}`,
102+
);
103+
}
104+
105+
const result = await page.pptrPage.evaluate(
106+
async (name, args) => {
107+
if (!window.__dtmcp?.executeTool) {
108+
throw new Error('No tools found on the page');
109+
}
110+
const toolResult = await window.__dtmcp.executeTool(name, args);
111+
112+
return {
113+
result: toolResult,
114+
};
115+
},
116+
toolName,
117+
params,
118+
);
119+
response.appendResponseLine(JSON.stringify(result, null, 2));
120+
},
121+
});

src/tools/input.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ async function fillFormElement(
217217
}
218218
}
219219

220+
// here
220221
export const fill = definePageTool({
221222
name: 'fill',
222223
description: `Type text into a input, text area or select an option from a <select> element.`,

tests/e2e/chrome-devtools.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import assert from 'node:assert';
88
import {spawn} from 'node:child_process';
9+
import fs from 'node:fs';
10+
import os from 'node:os';
911
import path from 'node:path';
1012
import {describe, it, afterEach, beforeEach} from 'node:test';
1113

@@ -93,6 +95,29 @@ describe('chrome-devtools', () => {
9395
await assertDaemonIsNotRunning();
9496
});
9597

98+
it('can start the daemon with userDataDir', async () => {
99+
const userDataDir = path.join(
100+
os.tmpdir(),
101+
`chrome-devtools-test-${crypto.randomUUID()}`,
102+
);
103+
fs.mkdirSync(userDataDir, {recursive: true});
104+
105+
const startResult = await runCli(['start', '--userDataDir', userDataDir]);
106+
assert.strictEqual(
107+
startResult.status,
108+
0,
109+
`start command failed: ${startResult.stderr}`,
110+
);
111+
assert.ok(
112+
!startResult.stderr.includes(
113+
'Arguments userDataDir and isolated are mutually exclusive',
114+
),
115+
`unexpected conflict error: ${startResult.stderr}`,
116+
);
117+
118+
await assertDaemonIsRunning();
119+
});
120+
96121
it('can invoke list_pages', async () => {
97122
await assertDaemonIsNotRunning();
98123

0 commit comments

Comments
 (0)