Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,17 +354,19 @@

### `evaluate_script`

**Description:** Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON, so returned values have to be JSON-serializable.
**Description:** Evaluate JavaScript inside the currently selected page. The source can be provided inline or loaded from a local file. Returns the response as JSON, so returned values have to be JSON-serializable.

**Parameters:**

- **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the currently selected page.
Example without arguments: `() => document.title` or `async () => await fetch("example.com")`.
Example with arguments: `(el) => el.innerText`

- **args** (array) _(optional)_: An optional list of arguments to pass to the function.
- **dialogAction** (string) _(optional)_: Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.
- **filePath** (string) _(optional)_: The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.
- **format** (enum: "function", "script") _(optional)_: How to interpret the source. "function" treats it as a function declaration and supports args. "script" evaluates it as classic JavaScript and does not support args. Defaults to "function". ECMAScript modules are not supported.
- **function** (string) _(optional)_: JavaScript source to execute in the currently selected page. Provide either this or sourcePath, but not both. The source is interpreted according to format.
Example without arguments: `() => document.title` or `async () => await fetch("example.com")`.
Example with arguments: `(el) => el.innerText`

- **sourcePath** (string) _(optional)_: The absolute or relative path to a JavaScript file on the MCP server's local filesystem. Provide either this or function, but not both.
- **waitForStableDom** (boolean) _(optional)_: Whether to wait for the DOM to settle. Pass false if the script only reads data. Defaults to true.

---
Expand Down
1 change: 1 addition & 0 deletions skills/chrome-devtools-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ chrome-devtools list_network_requests --includePreservedRequests true # Include
```bash
chrome-devtools evaluate_script "() => document.title" # Evaluate a JavaScript function on the page
chrome-devtools evaluate_script "(a) => a.innerText" --args 1_4 # Evaluate JS with UID arguments
chrome-devtools evaluate_script --sourcePath ./script.js --format script # Evaluate a local classic JavaScript file
chrome-devtools get_console_message 1 # Gets a console message by its ID
chrome-devtools lighthouse_audit --mode "navigation" # Run Lighthouse audit for navigation
chrome-devtools lighthouse_audit --mode "snapshot" --device "mobile" # Run Lighthouse audit for a snapshot on mobile
Expand Down
20 changes: 17 additions & 3 deletions src/bin/chrome-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ await checkForUpdates(
);

const DEFAULT_CLI_ARGS = ['--viaCli'];
const OPTIONAL_POSITIONAL_ARGS = new Set(['evaluate_script:function']);

async function start(args: string[], sessionId: string) {
const combinedArgs = [...DEFAULT_CLI_ARGS, ...args];
Expand Down Expand Up @@ -109,7 +110,7 @@ const y = yargs(hideBin(process.argv))
' - CORRECT: chrome-devtools evaluate_script "() => document.title"',
);
console.error(
'2. Optional parameters are passed as double-dash options/flags (e.g. --pageId 1).',
'2. Optional parameters are passed as double-dash options/flags (e.g. --pageId 1), except optional positional parameters shown in command help.',
);
console.error(
'3. Make sure to escape quotes properly for your shell environment.',
Expand Down Expand Up @@ -220,13 +221,23 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
);

const optionalArgNames = Object.keys(args).filter(
name => !args[name].required,
name =>
!args[name].required &&
!OPTIONAL_POSITIONAL_ARGS.has(`${commandName}:${name}`),
);
const optionalPositionalArgNames = Object.keys(args).filter(
name =>
!args[name].required &&
OPTIONAL_POSITIONAL_ARGS.has(`${commandName}:${name}`),
);

let commandStr = commandName;
for (const arg of requiredArgNames) {
commandStr += ` <${arg}>`;
}
for (const arg of optionalPositionalArgNames) {
commandStr += ` [${arg}]`;
}

for (const arg of optionalArgNames) {
commandStr += ` [--${arg}]`;
Expand All @@ -250,7 +261,10 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
? 'array'
: 'string';

if (opt.required) {
if (
opt.required ||
OPTIONAL_POSITIONAL_ARGS.has(`${commandName}:${argName}`)
) {
const options: PositionalOptions = {
describe: opt.description,
type: type as PositionalOptions['type'],
Expand Down
21 changes: 18 additions & 3 deletions src/config/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,15 +223,30 @@ export const commands: Commands = {
},
evaluate_script: {
description:
'Evaluate a JavaScript function inside the currently selected page or service worker. Returns the response as JSON, so returned values have to be JSON-serializable.',
'Evaluate JavaScript inside the currently selected page or service worker. The source can be provided inline or loaded from a local file. Returns the response as JSON, so returned values have to be JSON-serializable.',
category: 'Debugging',
args: {
function: {
name: 'function',
type: 'string',
description:
'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => document.title` or `async () => await fetch("example.com")`.\nExample with arguments: `(el) => el.innerText`\n',
required: true,
'JavaScript source to execute in the currently selected page. Provide either this or sourcePath, but not both. The source is interpreted according to format.\nExample without arguments: `() => document.title` or `async () => await fetch("example.com")`.\nExample with arguments: `(el) => el.innerText`\n',
required: false,
},
sourcePath: {
name: 'sourcePath',
type: 'string',
description:
"The absolute or relative path to a JavaScript file on the MCP server's local filesystem. Provide either this or function, but not both.",
required: false,
},
format: {
name: 'format',
type: 'string',
description:
'How to interpret the source. "function" treats it as a function declaration and supports args. "script" evaluates it as classic JavaScript and does not support args. Defaults to "function". ECMAScript modules are not supported.',
required: false,
enum: ['function', 'script'],
},
args: {
name: 'args',
Expand Down
8 changes: 8 additions & 0 deletions src/telemetry/tool_call_metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@
{
"name": "wait_for_stable_dom",
"argType": "boolean"
},
{
"name": "source_path_length",
"argType": "number"
},
{
"name": "format",
"argType": "string"
}
]
},
Expand Down
95 changes: 77 additions & 18 deletions src/tools/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {readFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';

import {zod} from '../third_party/index.js';
import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js';
import type {ExtensionServiceWorker} from '../types.js';
Expand All @@ -17,19 +20,34 @@ export type Evaluatable = Page | Frame | WebWorker;
export const evaluateScript = defineTool(cliArgs => {
return {
name: 'evaluate_script',
description: `Evaluate a JavaScript function inside the currently selected page${cliArgs?.categoryExtensions ? ' or service worker' : ''}. Returns the response as JSON, so returned values have to be JSON-serializable.`,
description: `Evaluate JavaScript inside the currently selected page${cliArgs?.categoryExtensions ? ' or service worker' : ''}. The source can be provided inline or loaded from a local file. Returns the response as JSON, so returned values have to be JSON-serializable.`,
annotations: {
category: ToolCategory.DEBUGGING,
readOnlyHint: false,
},
schema: {
...(cliArgs?.experimentalPageIdRouting ? pageIdSchema : {}),
function: zod.string().describe(
`A JavaScript function declaration to be executed by the tool in the currently selected page.
function: zod
.string()
.optional()
.describe(
`JavaScript source to execute in the currently selected page. Provide either this or sourcePath, but not both. The source is interpreted according to format.
Example without arguments: \`() => document.title\` or \`async () => await fetch("example.com")\`.
Example with arguments: \`(el) => el.innerText\`
`,
),
),
sourcePath: zod
.string()
.optional()
.describe(
"The absolute or relative path to a JavaScript file on the MCP server's local filesystem. Provide either this or function, but not both.",
),
format: zod
.enum(['function', 'script'])
.optional()
.describe(
'How to interpret the source. "function" treats it as a function declaration and supports args. "script" evaluates it as classic JavaScript and does not support args. Defaults to "function". ECMAScript modules are not supported.',
),
args: zod
.array(
zod
Expand Down Expand Up @@ -72,18 +90,26 @@ Example with arguments: \`(el) => el.innerText\`
blockedByDialog: true,
verifyFilesSchema: {
filePath: true,
sourcePath: true,
},
handler: async (request, response, context) => {
const {
serviceWorkerId,
args: uidArgs,
function: fnString,
sourcePath,
format = 'function',
pageId,
dialogAction,
filePath,
waitForStableDom,
} = request.params;

const source = await resolveScriptSource(fnString, sourcePath);
if (format === 'script' && uidArgs && uidArgs.length > 0) {
throw new Error('args cannot be used when format is "script".');
}

if (cliArgs?.categoryExtensions && serviceWorkerId) {
if (uidArgs && uidArgs.length > 0) {
throw new Error(
Expand All @@ -99,7 +125,7 @@ Example with arguments: \`(el) => el.innerText\`
.getSelectedMcpPage()
.waitForEventsAfterAction(
async () => {
await performEvaluation(worker, fnString, [], response, {
await performEvaluation(worker, source, format, [], response, {
filePath,
context,
});
Expand Down Expand Up @@ -134,7 +160,7 @@ Example with arguments: \`(el) => el.innerText\`

const result = await mcpPage.waitForEventsAfterAction(
async () => {
await performEvaluation(evaluatable, fnString, args, response, {
await performEvaluation(evaluatable, source, format, args, response, {
filePath,
context,
});
Expand All @@ -146,24 +172,57 @@ Example with arguments: \`(el) => el.innerText\`
};
});

const resolveScriptSource = async (
inlineSource: string | undefined,
sourcePath: string | undefined,
): Promise<string> => {
if (inlineSource !== undefined) {
if (sourcePath !== undefined) {
throw new Error('Specify exactly one of function or sourcePath.');
}
return inlineSource;
}
if (sourcePath === undefined) {
throw new Error('Specify exactly one of function or sourcePath.');
}

const resolvedPath = sourcePath.startsWith('file:')
? fileURLToPath(sourcePath)
: sourcePath;
try {
return await readFile(resolvedPath, 'utf8');
} catch (error) {
throw new Error(`Unable to read script source from ${sourcePath}.`, {
cause: error,
});
}
};

const performEvaluation = async (
evaluatable: Evaluatable,
fnString: string,
source: string,
format: 'function' | 'script',
args: Array<JSHandle<unknown>>,
response: Response,
options?: {filePath: string; context: Context},
options: {filePath?: string; context: Context},
) => {
using fn = await evaluatable.evaluateHandle(`(${fnString})`);
let result: string | undefined;
if (format === 'function') {
using fn = await evaluatable.evaluateHandle(`(${source})`);
result = await evaluatable.evaluate(
async (fn, ...args) => {
// @ts-expect-error no types for function fn
return JSON.stringify(await fn(...args));
},
fn,
...args,
);
} else {
const value = await evaluatable.evaluate(source);
result = JSON.stringify(value);
}

const result = await evaluatable.evaluate(
async (fn, ...args) => {
// @ts-expect-error no types for function fn
return JSON.stringify(await fn(...args));
},
fn,
...args,
);
if (options?.filePath) {
if (options.filePath) {
const data = new TextEncoder().encode(result ?? 'undefined');
const {filename} = await options.context.saveFile(
data,
Expand Down
47 changes: 47 additions & 0 deletions tests/e2e/chrome-devtools-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

import assert from 'node:assert';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {describe, it, afterEach, beforeEach} from 'node:test';

import {
Expand Down Expand Up @@ -72,6 +75,50 @@ describe('chrome-devtools', () => {
);
});

it('can evaluate inline and local JavaScript', async () => {
const startResult = await runCli(['start'], sessionId);
assert.strictEqual(
startResult.status,
0,
`start command failed: ${startResult.stderr}`,
);

const inlineResult = await runCli(
['evaluate_script', '() => 6 * 7'],
sessionId,
);
assert.strictEqual(
inlineResult.status,
0,
`inline evaluation failed: ${inlineResult.stderr}`,
);
assert.match(inlineResult.stdout, /\b42\b/);

const directory = await fs.mkdtemp(
path.join(os.tmpdir(), 'evaluate-script-cli-'),
);
const sourcePath = path.join(directory, 'script.js');
try {
await fs.writeFile(
sourcePath,
'document.title = "Local script"; document.title',
'utf8',
);
const fileResult = await runCli(
['evaluate_script', '--sourcePath', sourcePath, '--format', 'script'],
sessionId,
);
assert.strictEqual(
fileResult.status,
0,
`file evaluation failed: ${fileResult.stderr}`,
);
assert.match(fileResult.stdout, /Local script/);
} finally {
await fs.rm(directory, {recursive: true, force: true});
}
});

it('fails to invoke list_network_requests when categoryNetwork is disabled', async () => {
await runCli(['start', '--categoryNetwork=false'], sessionId);

Expand Down
Loading