refactor(tools): add explicit factory types to tool definitions (#3284)

Introduces `ToolSchema`, `ToolFactory`, `PageToolFactory`, and
`BrowserlessToolFactory` in `src/tools/ToolDefinition.ts`, streamlines
the schema type constraints to use `ToolSchema` consistently, and
annotates all exported tool definitions with their factory types.

The end goal of this stack is to enable TypeScript's
`isolatedDeclarations` compiler option across the codebase.

No user-visible behavior change.
This commit is contained in:
Nikolay Vitkov authored and GitHub committed 2026-10-21 12:41:59 +00:00
1 parent 4723335294
commit 4d1d7b21c8
22 files changed
+1647 -1551

No files matched your search

+48 -41
View File
@@ -78,8 +78,6 @@ export type FileVerificationOption =
allowUrl?: boolean;
};
type AllKeys<T> = T extends unknown ? keyof T : never;
/**
* Which task queue `ToolHandler` runs a tool call on. A task on a queue
* waits for tasks on the same queue, on its parent, and on its children.
@@ -99,18 +97,20 @@ type AllKeys<T> = T extends unknown ? keyof T : never;
*/
export type ToolConcurrency = 'exclusive' | 'page' | 'none';
type ExtractSchemaField<Schema, K extends PropertyKey> = Schema extends unknown
type AllKeys<T> = T extends unknown ? keyof T : never;
type SchemaField<Schema, K extends PropertyKey> = Schema extends unknown
? K extends keyof Schema
? Exclude<Schema[K], undefined>
: never
? Schema[K]
: undefined
: never;
export type MergeSchema<Schema extends zod.ZodRawShape> = {
[K in AllKeys<Schema>]: K extends keyof Schema
? undefined extends Schema[K]
? zod.ZodOptional<ExtractSchemaField<Schema, K>>
: Schema[K]
: zod.ZodOptional<ExtractSchemaField<Schema, K>>;
export type ToolSchema = Record<string, zod.ZodType>;
type MergeSchema<Schema extends ToolSchema> = {
[K in AllKeys<Schema>]: undefined extends SchemaField<Schema, K>
? zod.ZodOptional<Exclude<SchemaField<Schema, K>, undefined>>
: SchemaField<Schema, K>;
};
export interface CapabilityGate {
@@ -118,9 +118,7 @@ export interface CapabilityGate {
conditions?: Array<keyof ParsedArguments>;
}
export interface BaseToolDefinition<
Schema extends zod.ZodRawShape = zod.ZodRawShape,
> {
export interface BaseToolDefinition<Schema extends ToolSchema = ToolSchema> {
name: string;
description: string;
annotations: {
@@ -144,15 +142,12 @@ export interface BaseToolDefinition<
* {@link ToolConcurrency}.
*/
concurrency: ToolConcurrency;
verifyFilesSchema: Partial<
Record<keyof MergeSchema<Schema>, FileVerificationOption>
>;
verifyFilesSchema: Partial<Record<AllKeys<Schema>, FileVerificationOption>>;
}
export interface ToolDefinition<
Schema extends zod.ZodRawShape = zod.ZodRawShape,
Schema extends ToolSchema = ToolSchema,
> extends BaseToolDefinition<Schema> {
schema: Schema;
/**
* Only page-scoped tools are checked for open dialogs by ToolHandler, so a
* non-page tool must handle dialogs itself.
@@ -170,12 +165,8 @@ export interface ToolDefinition<
): Promise<void>;
}
export type SchemaType<T extends zod.ZodRawShape> = zod.output<
zod.ZodObject<MergeSchema<T>>
>;
export interface Request<Schema extends zod.ZodRawShape> {
params: SchemaType<Schema>;
export interface Request<Schema extends ToolSchema> {
params: zod.output<zod.ZodObject<MergeSchema<Schema>>>;
}
export interface ImageContentData {
@@ -551,14 +542,18 @@ export type ContextPage = Readonly<{
lighthouseSnapshot(flags?: Flags): Promise<RunnerResult | undefined>;
}>;
export function defineTool<Schema extends zod.ZodRawShape>(
definition: (args: ParsedArguments) => ToolDefinition<Schema>,
): (args: ParsedArguments) => ToolDefinition<Schema> {
export type ToolFactory<Schema extends ToolSchema = ToolSchema> = (
args: ParsedArguments,
) => ToolDefinition<Schema>;
export function defineTool<Schema extends ToolSchema>(
definition: ToolFactory<Schema>,
): ToolFactory<Schema> {
return definition;
}
interface PageToolDefinition<
Schema extends zod.ZodRawShape = zod.ZodRawShape,
Schema extends ToolSchema = ToolSchema,
> extends BaseToolDefinition<Schema> {
/**
* A page tool with `'none'` must only read page state. Its page lookup and
@@ -572,15 +567,19 @@ interface PageToolDefinition<
): Promise<void>;
}
export type DefinedPageTool<Schema extends zod.ZodRawShape = zod.ZodRawShape> =
Omit<PageToolDefinition<Schema>, 'schema'> & {
schema: Schema & Partial<typeof pageIdSchema>;
pageScoped: true;
};
export interface DefinedPageTool<
Schema extends ToolSchema = ToolSchema,
> extends PageToolDefinition<Schema> {
pageScoped: true;
}
export function definePageTool<Schema extends zod.ZodRawShape>(
export type PageToolFactory<Schema extends ToolSchema = ToolSchema> = (
args: ParsedArguments,
) => DefinedPageTool<Schema>;
export function definePageTool<Schema extends ToolSchema>(
definition: (args: ParsedArguments) => PageToolDefinition<Schema>,
): (args: ParsedArguments) => DefinedPageTool<Schema> {
): PageToolFactory<Schema> {
return (args: ParsedArguments): DefinedPageTool<Schema> => {
const tool = definition(args);
return {
@@ -595,7 +594,7 @@ export function definePageTool<Schema extends zod.ZodRawShape>(
}
export interface BrowserlessToolDefinition<
Schema extends zod.ZodRawShape = zod.ZodRawShape,
Schema extends ToolSchema = ToolSchema,
> extends BaseToolDefinition<Schema> {
browserless: true;
/**
@@ -614,18 +613,22 @@ export interface BrowserlessToolDefinition<
): Promise<void>;
}
export type BrowserlessToolFactory<Schema extends ToolSchema = ToolSchema> = (
args: ParsedArguments,
) => BrowserlessToolDefinition<Schema>;
/**
* Defines a tool that does not need a browser connection, e.g. a tool that only
* analyzes local files. Its handler only receives a {@link BrowserlessContext}.
* Such a tool runs without the context queue (`concurrency: 'none'`) unless
* the definition sets another value.
*/
export function defineBrowserlessTool<Schema extends zod.ZodRawShape>(
export function defineBrowserlessTool<Schema extends ToolSchema>(
definition: (
args: ParsedArguments,
) => Omit<BrowserlessToolDefinition<Schema>, 'browserless' | 'concurrency'> &
Partial<Pick<BrowserlessToolDefinition<Schema>, 'concurrency'>>,
): (args: ParsedArguments) => BrowserlessToolDefinition<Schema> {
): BrowserlessToolFactory<Schema> {
return (args: ParsedArguments): BrowserlessToolDefinition<Schema> => {
return {
concurrency: 'none',
@@ -653,7 +656,9 @@ export function isBrowserlessTool(
export const CLOSE_PAGE_ERROR =
'The last open page cannot be closed. It is fine to keep it open.';
export const pageIdSchema = {
export const pageIdSchema: {
pageId: zod.ZodNumber;
} = {
pageId: zod.number().describe('Targets a specific page by ID.'),
};
@@ -673,7 +678,9 @@ export function zodTransform<Input, Output>(
};
}
export const timeoutSchema = {
export const timeoutSchema: {
timeout: zod.ZodOptional<zod.ZodType<number | undefined>>;
} = {
timeout: zod
.number()
.int()
+5 -5
View File
@@ -9,7 +9,7 @@ import {zod} from '../third_party/index.js';
import type {CD4ACommentThread, CD4ARevealTarget} from '../types.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
import {definePageTool, type PageToolFactory} from './ToolDefinition.js';
export type CommentThreadPayload = CD4ACommentThread;
export type RevealTargetPayload = CD4ARevealTarget;
@@ -17,7 +17,7 @@ export type RevealTargetPayload = CD4ARevealTarget;
const DEVTOOLS_COMMENTS_UNAVAILABLE_MESSAGE =
'DevTools comments are not working right now. Possible reasons: the connected Chrome version does not support DevTools comments, the feature is disabled, or the DevTools window has not finished loading. Try again later, or continue without DevTools comments.';
export const openDevtools = definePageTool(() => ({
export const openDevtools: PageToolFactory = definePageTool(() => ({
name: 'open_devtools',
description: 'Open a DevTools window for the selected page.',
annotations: {
@@ -45,7 +45,7 @@ export const openDevtools = definePageTool(() => ({
const DEFAULT_COMMENT_WAIT_MS = 10_000;
const MAX_COMMENT_WAIT_MS = 30_000;
export const getDevtoolsComments = definePageTool(() => ({
export const getDevtoolsComments: PageToolFactory = definePageTool(() => ({
name: 'get_devtools_comments',
description:
'Retrieve user comments from the DevTools window for the page. ' +
@@ -117,7 +117,7 @@ export const getDevtoolsComments = definePageTool(() => ({
},
}));
export const resolveDevtoolsComment = definePageTool(() => ({
export const resolveDevtoolsComment: PageToolFactory = definePageTool(() => ({
name: 'resolve_devtools_comment',
description:
'Append an agent reply to a DevTools comment thread and mark it as resolved.',
@@ -183,7 +183,7 @@ export const resolveDevtoolsComment = definePageTool(() => ({
},
}));
export const revealInDevtools = definePageTool(() => ({
export const revealInDevtools: PageToolFactory = definePageTool(() => ({
name: 'reveal_in_devtools',
description:
'Navigate DevTools to a specified panel and highlight a target DOM node or network request. The parameters uid and reqid are mutually exclusive.',
+3 -3
View File
@@ -9,7 +9,7 @@ import {zod} from '../third_party/index.js';
import type {ConsoleMessageType} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
import {definePageTool, type PageToolFactory} from './ToolDefinition.js';
type ConsoleResponseType = ConsoleMessageType | 'issue';
const FILTERABLE_MESSAGE_TYPES: [
@@ -40,7 +40,7 @@ const FILTERABLE_MESSAGE_TYPES: [
const LIST_CONSOLE_MESSAGES_TOOL_NAME = 'list_console_messages';
export const listConsoleMessages = definePageTool(
export const listConsoleMessages: PageToolFactory = definePageTool(
(cliArgs: ParsedArguments) => {
return {
name: LIST_CONSOLE_MESSAGES_TOOL_NAME,
@@ -110,7 +110,7 @@ export const listConsoleMessages = definePageTool(
},
);
export const getConsoleMessage = definePageTool(() => ({
export const getConsoleMessage: PageToolFactory = definePageTool(() => ({
name: 'get_console_message',
description: `Gets a console message by its ID. You can get all messages by calling ${LIST_CONSOLE_MESSAGES_TOOL_NAME}.`,
annotations: {
+2 -2
View File
@@ -7,9 +7,9 @@
import {zod} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
import {definePageTool, type PageToolFactory} from './ToolDefinition.js';
export const getCssStyles = definePageTool(() => ({
export const getCssStyles: PageToolFactory = definePageTool(() => ({
name: 'get_css_styles',
description: `Retrieve matched CSS rules, inline styles (element.style), inherited styles, custom properties, and cascade wrappers (@layer, @media, @container, @scope) for an element identified by its UID.
Rules are ordered from highest to lowest cascade precedence and include source line numbers (e.g. index:196). Active (winning) declarations have no prefix tag, while (losing) overridden declarations are prefixed with [overloaded].
+2 -1
View File
@@ -15,6 +15,7 @@ import {
import {ToolCategory} from './categories.js';
import {
definePageTool,
type PageToolFactory,
geolocationTransform,
viewportTransform,
zodTransform,
@@ -51,7 +52,7 @@ const throttlingOptions: [string, ...string[]] = [
...Object.keys(PredefinedNetworkConditions),
];
export const emulate = definePageTool(() => ({
export const emulate: PageToolFactory = definePageTool(() => ({
name: 'emulate',
description: `Emulates various features on the target page. Each call replaces all emulation settings (except extraHttpHeaders), so pass all desired settings in a single call.`,
annotations: {
+6 -6
View File
@@ -7,9 +7,9 @@
import {zod} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {defineTool} from './ToolDefinition.js';
import {defineTool, type ToolFactory} from './ToolDefinition.js';
export const installExtension = defineTool(() => ({
export const installExtension: ToolFactory = defineTool(() => ({
name: 'install_extension',
description: 'Installs a Chrome extension from the given path.',
annotations: {
@@ -33,7 +33,7 @@ export const installExtension = defineTool(() => ({
},
}));
export const uninstallExtension = defineTool(() => ({
export const uninstallExtension: ToolFactory = defineTool(() => ({
name: 'uninstall_extension',
description: 'Uninstalls a Chrome extension by its ID.',
annotations: {
@@ -53,7 +53,7 @@ export const uninstallExtension = defineTool(() => ({
},
}));
export const listExtensions = defineTool(() => ({
export const listExtensions: ToolFactory = defineTool(() => ({
name: 'list_extensions',
description:
'Lists all the Chrome extensions installed in the browser. This includes their name, ID, version, and enabled status.',
@@ -70,7 +70,7 @@ export const listExtensions = defineTool(() => ({
},
}));
export const reloadExtension = defineTool(() => ({
export const reloadExtension: ToolFactory = defineTool(() => ({
name: 'reload_extension',
description: 'Reloads an unpacked Chrome extension by its ID.',
annotations: {
@@ -94,7 +94,7 @@ export const reloadExtension = defineTool(() => ({
},
}));
export const triggerExtensionAction = defineTool(() => ({
export const triggerExtensionAction: ToolFactory = defineTool(() => ({
name: 'trigger_extension_action',
description: 'Triggers the default action of an extension by its ID.',
annotations: {
+10 -10
View File
@@ -12,7 +12,7 @@ import {logger} from '../utils/logger.js';
import type {WaitForEventsResult} from '../utils/WaitForHelper.js';
import {ToolCategory} from './categories.js';
import type {ContextPage} from './ToolDefinition.js';
import type {ContextPage, PageToolFactory} from './ToolDefinition.js';
import {definePageTool} from './ToolDefinition.js';
const dblClickSchema = zod
@@ -108,7 +108,7 @@ async function selectNativeSelectOption(
return true;
}
export const click = definePageTool(() => ({
export const click: PageToolFactory = definePageTool(() => ({
name: 'click',
description: `Clicks on the provided element`,
annotations: {
@@ -169,7 +169,7 @@ export const click = definePageTool(() => ({
},
}));
export const clickAt = definePageTool(() => ({
export const clickAt: PageToolFactory = definePageTool(() => ({
name: 'click_at',
description: `Clicks at the provided coordinates`,
annotations: {
@@ -205,7 +205,7 @@ export const clickAt = definePageTool(() => ({
},
}));
export const hover = definePageTool(() => ({
export const hover: PageToolFactory = definePageTool(() => ({
name: 'hover',
description: `Hover over the provided element`,
annotations: {
@@ -393,7 +393,7 @@ async function fillFormElements(
}
}
export const fill = definePageTool(() => ({
export const fill: PageToolFactory = definePageTool(() => ({
name: 'fill',
description: `Type text into an input, text area or select an option from a <select> element.`,
annotations: {
@@ -435,7 +435,7 @@ export const fill = definePageTool(() => ({
},
}));
export const typeText = definePageTool(() => ({
export const typeText: PageToolFactory = definePageTool(() => ({
name: 'type_text',
description: `Type text using keyboard into a previously focused input`,
annotations: {
@@ -466,7 +466,7 @@ export const typeText = definePageTool(() => ({
},
}));
export const drag = definePageTool(() => ({
export const drag: PageToolFactory = definePageTool(() => ({
name: 'drag',
description: `Drag an element onto another element`,
annotations: {
@@ -500,7 +500,7 @@ export const drag = definePageTool(() => ({
},
}));
export const fillForm = definePageTool(() => ({
export const fillForm: PageToolFactory = definePageTool(() => ({
name: 'fill_form',
description: `Fill out multiple form elements (inputs, selects, checkboxes, radios) at once. ALWAYS prefer this tool over multiple individual 'fill' or 'click' calls when interacting with forms. It is significantly faster, more reliable, and reduces turn count. Example: Fill username, password, and check "Remember Me" in one call.`,
annotations: {
@@ -556,7 +556,7 @@ export const fillForm = definePageTool(() => ({
},
}));
export const uploadFile = definePageTool(() => ({
export const uploadFile: PageToolFactory = definePageTool(() => ({
name: 'upload_file',
description: 'Upload a file through a provided element.',
annotations: {
@@ -625,7 +625,7 @@ export const uploadFile = definePageTool(() => ({
},
}));
export const pressKey = definePageTool(() => ({
export const pressKey: PageToolFactory = definePageTool(() => ({
name: 'press_key',
description: `Press a key or key combination. Use this when other input methods like fill() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations).`,
annotations: {
+154 -151
View File
@@ -23,6 +23,7 @@ import {
definePageTool,
type LighthouseAuditNode,
type LighthouseFailedAudit,
type PageToolFactory,
} from './ToolDefinition.js';
// Kept in sync with `constants.userAgents` in Lighthouse's
@@ -165,174 +166,176 @@ const getFailedAudits = (lhr: Result): LighthouseFailedAudit[] => {
return failedAudits;
};
export const lighthouseAudit = definePageTool((args: ParsedArguments) => ({
name: 'lighthouse_audit',
description: `Get Lighthouse scores, failing audits, and the DOM nodes they flag for accessibility, SEO, best practices, and agentic browsing. Full reports are saved to disk. This excludes performance. For performance audits, run ${startTrace(args).name}`,
annotations: {
category: ToolCategory.DEBUGGING,
readOnlyHint: false,
},
schema: {
mode: zod
.enum(['navigation', 'snapshot'])
.default('navigation')
.describe(
'"navigation" reloads & audits. "snapshot" analyzes current state.',
),
device: zod
.enum(['desktop', 'mobile'])
.default('desktop')
.describe('Device to emulate.'),
outputDirPath: zod
.string()
.optional()
.describe('Directory for reports. If omitted, uses temporary files.'),
},
blockedByDialog: true,
concurrency: 'exclusive',
verifyFilesSchema: {
outputDirPath: true,
},
handler: async (request, response, context) => {
const page = request.page;
const categories = [
'accessibility',
'seo',
'best-practices',
'agentic-browsing',
];
const formats: OutputMode[] = ['json', 'html'];
const {
mode = 'navigation',
device = 'desktop',
outputDirPath,
} = request.params;
export const lighthouseAudit: PageToolFactory = definePageTool(
(args: ParsedArguments) => ({
name: 'lighthouse_audit',
description: `Get Lighthouse scores, failing audits, and the DOM nodes they flag for accessibility, SEO, best practices, and agentic browsing. Full reports are saved to disk. This excludes performance. For performance audits, run ${startTrace(args).name}`,
annotations: {
category: ToolCategory.DEBUGGING,
readOnlyHint: false,
},
schema: {
mode: zod
.enum(['navigation', 'snapshot'])
.default('navigation')
.describe(
'"navigation" reloads & audits. "snapshot" analyzes current state.',
),
device: zod
.enum(['desktop', 'mobile'])
.default('desktop')
.describe('Device to emulate.'),
outputDirPath: zod
.string()
.optional()
.describe('Directory for reports. If omitted, uses temporary files.'),
},
blockedByDialog: true,
concurrency: 'exclusive',
verifyFilesSchema: {
outputDirPath: true,
},
handler: async (request, response, context) => {
const page = request.page;
const categories = [
'accessibility',
'seo',
'best-practices',
'agentic-browsing',
];
const formats: OutputMode[] = ['json', 'html'];
const {
mode = 'navigation',
device = 'desktop',
outputDirPath,
} = request.params;
const flags: Flags = {
onlyCategories: categories,
output: formats,
// Default 30 second timeout for page load.
maxWaitForLoad: 30_000,
};
if (device === 'desktop') {
flags.formFactor = 'desktop';
flags.screenEmulation = {
mobile: false,
width: 1350,
height: 940,
deviceScaleFactor: 1,
disabled: false,
const flags: Flags = {
onlyCategories: categories,
output: formats,
// Default 30 second timeout for page load.
maxWaitForLoad: 30_000,
};
flags.emulatedUserAgent = DESKTOP_USER_AGENT;
} else {
flags.formFactor = 'mobile';
flags.screenEmulation = {
mobile: true,
width: 412,
height: 823,
deviceScaleFactor: 1.75,
disabled: false,
};
flags.emulatedUserAgent = MOBILE_USER_AGENT;
}
let result: RunnerResult | undefined;
try {
if (mode === 'navigation') {
try {
result = await page.lighthouseNavigation(flags);
} catch (error) {
if (isInvalidUrlError(error)) {
const url = page.pptrPage.url();
throw new Error(
`Lighthouse "navigation" mode cannot audit ${url}: it only supports http:, https:, chrome: and chrome-extension: URLs. Use mode "snapshot" to audit the page in its current state.`,
{cause: error},
);
}
throw error;
}
if (device === 'desktop') {
flags.formFactor = 'desktop';
flags.screenEmulation = {
mobile: false,
width: 1350,
height: 940,
deviceScaleFactor: 1,
disabled: false,
};
flags.emulatedUserAgent = DESKTOP_USER_AGENT;
} else {
result = await page.lighthouseSnapshot(flags);
flags.formFactor = 'mobile';
flags.screenEmulation = {
mobile: true,
width: 412,
height: 823,
deviceScaleFactor: 1.75,
disabled: false,
};
flags.emulatedUserAgent = MOBILE_USER_AGENT;
}
if (!result) {
throw new Error('Lighthouse audit failed.');
let result: RunnerResult | undefined;
try {
if (mode === 'navigation') {
try {
result = await page.lighthouseNavigation(flags);
} catch (error) {
if (isInvalidUrlError(error)) {
const url = page.pptrPage.url();
throw new Error(
`Lighthouse "navigation" mode cannot audit ${url}: it only supports http:, https:, chrome: and chrome-extension: URLs. Use mode "snapshot" to audit the page in its current state.`,
{cause: error},
);
}
throw error;
}
} else {
result = await page.lighthouseSnapshot(flags);
}
if (!result) {
throw new Error('Lighthouse audit failed.');
}
} finally {
await page.restoreEmulation();
}
} finally {
await page.restoreEmulation();
}
const lhr = result.lhr;
const reportPaths: string[] = [];
const lhr = result.lhr;
const reportPaths: string[] = [];
const encoder = new TextEncoder();
const savePromises = formats.map(async format => {
const report = await generateReport(lhr, format);
const data = encoder.encode(report);
if (outputDirPath) {
const reportPath = path.join(outputDirPath, `report`);
const {filename} = await context.saveFile(
const encoder = new TextEncoder();
const savePromises = formats.map(async format => {
const report = await generateReport(lhr, format);
const data = encoder.encode(report);
if (outputDirPath) {
const reportPath = path.join(outputDirPath, `report`);
const {filename} = await context.saveFile(
data,
reportPath,
`.${format}`,
);
return filename;
}
const {filepath} = await context.saveTemporaryFile(
data,
reportPath,
`.${format}`,
`report.${format}`,
);
return filename;
return filepath;
});
const results = await Promise.allSettled(savePromises);
for (const res of results) {
if (res.status === 'rejected') {
throw res.reason;
}
reportPaths.push(res.value);
}
const {filepath} = await context.saveTemporaryFile(
data,
`report.${format}`,
);
return filepath;
});
const results = await Promise.allSettled(savePromises);
for (const res of results) {
if (res.status === 'rejected') {
throw res.reason;
// Lighthouse sets runtimeError when it could not load the page; the categories are unscored.
if (lhr.runtimeError) {
throw new Error(
`Lighthouse could not audit the page (${lhr.runtimeError.code}): ${lhr.runtimeError.message} Reports: ${reportPaths.join(', ')}`,
);
}
reportPaths.push(res.value);
}
// Lighthouse sets runtimeError when it could not load the page; the categories are unscored.
if (lhr.runtimeError) {
throw new Error(
`Lighthouse could not audit the page (${lhr.runtimeError.code}): ${lhr.runtimeError.message} Reports: ${reportPaths.join(', ')}`,
);
}
const categoryScores = Object.values(lhr.categories).map(c => ({
id: c.id,
title: c.title,
score: c.score,
}));
const categoryScores = Object.values(lhr.categories).map(c => ({
id: c.id,
title: c.title,
score: c.score,
}));
const failedAudits = getFailedAudits(lhr);
const failedAudits = getFailedAudits(lhr);
const passedAudits = Object.values(lhr.audits).filter(
a => a.score === 1,
).length;
const passedAudits = Object.values(lhr.audits).filter(
a => a.score === 1,
).length;
const output = {
summary: {
mode,
device,
// `mainDocumentUrl` is only set for navigations, whereas
// `finalDisplayedUrl` is populated for every gather mode.
url: lhr.finalDisplayedUrl,
scores: categoryScores,
audits: {
failed: failedAudits.length,
passed: passedAudits,
const output = {
summary: {
mode,
device,
// `mainDocumentUrl` is only set for navigations, whereas
// `finalDisplayedUrl` is populated for every gather mode.
url: lhr.finalDisplayedUrl,
scores: categoryScores,
audits: {
failed: failedAudits.length,
passed: passedAudits,
},
timing: {
total: lhr.timing.total,
},
},
timing: {
total: lhr.timing.total,
},
},
failedAudits,
reports: reportPaths,
};
failedAudits,
reports: reportPaths,
};
response.attachLighthouseResult(output);
},
}));
response.attachLighthouseResult(output);
},
}),
);
+590 -544
View File
File diff suppressed because it is too large. Load diff
+60 -58
View File
@@ -9,7 +9,7 @@ import {zod} from '../third_party/index.js';
import type {ResourceType} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
import {definePageTool, type PageToolFactory} from './ToolDefinition.js';
type FilterableResourceType = Exclude<ResourceType, 'websocket'>;
@@ -37,64 +37,66 @@ const FILTERABLE_RESOURCE_TYPES: readonly [
'other',
];
export const listNetworkRequests = definePageTool((args: ParsedArguments) => ({
name: 'list_network_requests',
description: `Lists the most recent requests for the target page since the last navigation.`,
annotations: {
category: ToolCategory.NETWORK,
readOnlyHint: true,
},
schema: {
pageSize: zod
.number()
.int()
.positive()
.optional()
.describe(
'Maximum number of requests to return. When omitted, returns all requests.',
),
pageIdx: zod
.number()
.int()
.min(0)
.optional()
.describe(
'Page number to return (0-based). When omitted, returns the first page. Ignored unless pageSize is set.',
),
resourceTypes: zod
.array(zod.enum(FILTERABLE_RESOURCE_TYPES))
.optional()
.describe(
'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.',
),
includePreservedRequests: zod
.boolean()
.default(false)
.optional()
.describe(
`Set to true to return the preserved requests over the last ${args.maxNavigationSaved} navigations.`,
),
},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
const data = await request.page.getDevToolsData();
response.attachDevToolsData(data);
const reqid = data?.cdpRequestId
? request.page.resolveCdpRequestId(data.cdpRequestId)
: undefined;
response.setIncludeNetworkRequests(true, {
pageSize: request.params.pageSize,
pageIdx: request.params.pageIdx,
resourceTypes: request.params.resourceTypes,
includePreservedRequests: request.params.includePreservedRequests,
networkRequestIdInDevToolsUI: reqid,
});
},
}));
export const listNetworkRequests: PageToolFactory = definePageTool(
(args: ParsedArguments) => ({
name: 'list_network_requests',
description: `Lists the most recent requests for the target page since the last navigation.`,
annotations: {
category: ToolCategory.NETWORK,
readOnlyHint: true,
},
schema: {
pageSize: zod
.number()
.int()
.positive()
.optional()
.describe(
'Maximum number of requests to return. When omitted, returns all requests.',
),
pageIdx: zod
.number()
.int()
.min(0)
.optional()
.describe(
'Page number to return (0-based). When omitted, returns the first page. Ignored unless pageSize is set.',
),
resourceTypes: zod
.array(zod.enum(FILTERABLE_RESOURCE_TYPES))
.optional()
.describe(
'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.',
),
includePreservedRequests: zod
.boolean()
.default(false)
.optional()
.describe(
`Set to true to return the preserved requests over the last ${args.maxNavigationSaved} navigations.`,
),
},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
const data = await request.page.getDevToolsData();
response.attachDevToolsData(data);
const reqid = data?.cdpRequestId
? request.page.resolveCdpRequestId(data.cdpRequestId)
: undefined;
response.setIncludeNetworkRequests(true, {
pageSize: request.params.pageSize,
pageIdx: request.params.pageIdx,
resourceTypes: request.params.resourceTypes,
includePreservedRequests: request.params.includePreservedRequests,
networkRequestIdInDevToolsUI: reqid,
});
},
}),
);
export const getNetworkRequest = definePageTool(() => ({
export const getNetworkRequest: PageToolFactory = definePageTool(() => ({
name: 'get_network_request',
description: `Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel. Useful for inspecting request headers (including 'Cookie') and response headers (including 'Set-Cookie' and directives).`,
annotations: {
+168 -164
View File
@@ -13,13 +13,15 @@ import {validateUrl} from '../utils/url.js';
import {ToolCategory} from './categories.js';
import {
CLOSE_PAGE_ERROR,
type ContextPage,
definePageTool,
defineTool,
type PageToolFactory,
timeoutSchema,
type ToolFactory,
} from './ToolDefinition.js';
import type {ContextPage} from './ToolDefinition.js';
export const listPages = defineTool((args: ParsedArguments) => {
export const listPages: ToolFactory = defineTool((args: ParsedArguments) => {
return {
name: 'list_pages',
description: `Get a list of pages${args?.categoryExtensions ? ' including extension service workers' : ''} open in the browser.`,
@@ -39,7 +41,7 @@ export const listPages = defineTool((args: ParsedArguments) => {
};
});
export const selectPage = defineTool((args: ParsedArguments) => ({
export const selectPage: ToolFactory = defineTool((args: ParsedArguments) => ({
name: 'select_page',
description: `Select a page as a context for future tool calls.`,
annotations: {
@@ -73,7 +75,7 @@ export const selectPage = defineTool((args: ParsedArguments) => ({
},
}));
export const closePage = defineTool(() => ({
export const closePage: ToolFactory = defineTool(() => ({
name: 'close_page',
description: `Closes the page by its index. The last open page cannot be closed.`,
annotations: {
@@ -103,7 +105,7 @@ export const closePage = defineTool(() => ({
},
}));
export const newPage = defineTool((args: ParsedArguments) => {
export const newPage: ToolFactory = defineTool((args: ParsedArguments) => {
return {
name: 'new_page',
description: `Open a new tab and load a URL. Use project URL if not specified otherwise.`,
@@ -188,169 +190,171 @@ export const newPage = defineTool((args: ParsedArguments) => {
};
});
export const navigatePage = definePageTool((args: ParsedArguments) => {
return {
name: 'navigate_page',
description: `Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.`,
annotations: {
category: ToolCategory.NAVIGATION,
readOnlyHint: false,
},
schema: {
type: zod
.enum(['url', 'back', 'forward', 'reload'])
.optional()
.describe(
'Navigate the page by URL, back or forward in history, or reload.',
),
url: zod.string().optional().describe('Target URL (only type=url)'),
ignoreCache: zod
.boolean()
.optional()
.describe('Whether to ignore cache on reload.'),
handleBeforeUnload: zod
.enum(['accept', 'dismiss'])
.optional()
.describe(
'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.',
),
...(args?.javascriptEvaluation !== false
? {
initScript: zod
.string()
.optional()
.describe(
'A JavaScript script to be executed on each new document before any other scripts for the next navigation.',
),
}
: {}),
...timeoutSchema,
},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
const page = request.page;
const options = {
timeout: request.params.timeout,
};
if (!request.params.type && !request.params.url) {
throw new Error('Either URL or a type is required.');
}
if (!request.params.type) {
request.params.type = 'url';
}
if (request.params.url) {
validateUrl(request.params.url, {
javascriptEvaluation: args?.javascriptEvaluation,
categoryExtensions: args?.categoryExtensions,
fileNavigations: args?.fileNavigations,
});
}
let initScriptId: string | undefined;
if (request.params.initScript) {
const {identifier} = await page.pptrPage.evaluateOnNewDocument(
request.params.initScript,
);
initScriptId = identifier;
}
try {
const action = request.params.handleBeforeUnload ?? 'accept';
const result = await page.waitForEventsAfterAction(
async () => {
switch (request.params.type) {
case 'url':
if (!request.params.url) {
throw new Error(
'A URL is required for navigation of type=url.',
);
}
try {
await page.pptrPage.goto(request.params.url, options);
response.appendResponseLine(
`Successfully navigated to ${request.params.url}.`,
);
} catch (error) {
response.appendResponseLine(
`Unable to navigate in the selected page: ${error.message}.`,
);
}
break;
case 'back':
try {
await page.pptrPage.goBack(options);
response.appendResponseLine(
`Successfully navigated back to ${page.pptrPage.url()}.`,
);
} catch (error) {
response.appendResponseLine(
`Unable to navigate back in the selected page: ${error.message}.`,
);
}
break;
case 'forward':
try {
await page.pptrPage.goForward(options);
response.appendResponseLine(
`Successfully navigated forward to ${page.pptrPage.url()}.`,
);
} catch (error) {
response.appendResponseLine(
`Unable to navigate forward in the selected page: ${error.message}.`,
);
}
break;
case 'reload':
try {
await page.pptrPage.reload({
...options,
ignoreCache: request.params.ignoreCache,
});
response.appendResponseLine(
`Successfully reloaded the page.`,
);
} catch (error) {
response.appendResponseLine(
`Unable to reload the selected page: ${error.message}.`,
);
}
break;
export const navigatePage: PageToolFactory = definePageTool(
(args: ParsedArguments) => {
return {
name: 'navigate_page',
description: `Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.`,
annotations: {
category: ToolCategory.NAVIGATION,
readOnlyHint: false,
},
schema: {
type: zod
.enum(['url', 'back', 'forward', 'reload'])
.optional()
.describe(
'Navigate the page by URL, back or forward in history, or reload.',
),
url: zod.string().optional().describe('Target URL (only type=url)'),
ignoreCache: zod
.boolean()
.optional()
.describe('Whether to ignore cache on reload.'),
handleBeforeUnload: zod
.enum(['accept', 'dismiss'])
.optional()
.describe(
'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.',
),
...(args?.javascriptEvaluation !== false
? {
initScript: zod
.string()
.optional()
.describe(
'A JavaScript script to be executed on each new document before any other scripts for the next navigation.',
),
}
},
{
timeout: request.params.timeout,
handleDialog: {beforeunload: action},
},
);
if (result.dialogHandled) {
response.appendResponseLine(
`${action === 'dismiss' ? 'Dismissed' : 'Accepted'} a beforeunload dialog.`,
: {}),
...timeoutSchema,
},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
const page = request.page;
const options = {
timeout: request.params.timeout,
};
if (!request.params.type && !request.params.url) {
throw new Error('Either URL or a type is required.');
}
if (!request.params.type) {
request.params.type = 'url';
}
if (request.params.url) {
validateUrl(request.params.url, {
javascriptEvaluation: args?.javascriptEvaluation,
categoryExtensions: args?.categoryExtensions,
fileNavigations: args?.fileNavigations,
});
}
let initScriptId: string | undefined;
if (request.params.initScript) {
const {identifier} = await page.pptrPage.evaluateOnNewDocument(
request.params.initScript,
);
page.clearDialog();
initScriptId = identifier;
}
} finally {
if (initScriptId) {
await page.pptrPage
.removeScriptToEvaluateOnNewDocument(initScriptId)
.catch(error => {
logger?.(`Failed to remove init script`, error);
});
try {
const action = request.params.handleBeforeUnload ?? 'accept';
const result = await page.waitForEventsAfterAction(
async () => {
switch (request.params.type) {
case 'url':
if (!request.params.url) {
throw new Error(
'A URL is required for navigation of type=url.',
);
}
try {
await page.pptrPage.goto(request.params.url, options);
response.appendResponseLine(
`Successfully navigated to ${request.params.url}.`,
);
} catch (error) {
response.appendResponseLine(
`Unable to navigate in the selected page: ${error.message}.`,
);
}
break;
case 'back':
try {
await page.pptrPage.goBack(options);
response.appendResponseLine(
`Successfully navigated back to ${page.pptrPage.url()}.`,
);
} catch (error) {
response.appendResponseLine(
`Unable to navigate back in the selected page: ${error.message}.`,
);
}
break;
case 'forward':
try {
await page.pptrPage.goForward(options);
response.appendResponseLine(
`Successfully navigated forward to ${page.pptrPage.url()}.`,
);
} catch (error) {
response.appendResponseLine(
`Unable to navigate forward in the selected page: ${error.message}.`,
);
}
break;
case 'reload':
try {
await page.pptrPage.reload({
...options,
ignoreCache: request.params.ignoreCache,
});
response.appendResponseLine(
`Successfully reloaded the page.`,
);
} catch (error) {
response.appendResponseLine(
`Unable to reload the selected page: ${error.message}.`,
);
}
break;
}
},
{
timeout: request.params.timeout,
handleDialog: {beforeunload: action},
},
);
if (result.dialogHandled) {
response.appendResponseLine(
`${action === 'dismiss' ? 'Dismissed' : 'Accepted'} a beforeunload dialog.`,
);
page.clearDialog();
}
} finally {
if (initScriptId) {
await page.pptrPage
.removeScriptToEvaluateOnNewDocument(initScriptId)
.catch(error => {
logger?.(`Failed to remove init script`, error);
});
}
}
}
response.setIncludePages(true);
response.setListThirdPartyDeveloperTools();
response.setListWebMcpTools();
},
};
});
response.setIncludePages(true);
response.setListThirdPartyDeveloperTools();
response.setListWebMcpTools();
},
};
},
);
export const resizePage = definePageTool(() => ({
export const resizePage: PageToolFactory = definePageTool(() => ({
name: 'resize_page',
description: `Resizes the page's window so that the page has specified dimension. If the browser clamps the window size, the actual page dimensions are reported.`,
annotations: {
@@ -382,7 +386,7 @@ export const resizePage = definePageTool(() => ({
},
}));
export const handleDialog = definePageTool(() => ({
export const handleDialog: PageToolFactory = definePageTool(() => ({
name: 'handle_dialog',
description: `If a browser dialog was opened, use this command to handle it`,
annotations: {
@@ -436,7 +440,7 @@ export const handleDialog = definePageTool(() => ({
},
}));
export const getTabId = definePageTool(() => ({
export const getTabId: PageToolFactory = definePageTool(() => ({
name: 'get_tab_id',
description: `Get the tab ID of the page`,
annotations: {
+9 -4
View File
@@ -15,7 +15,12 @@ import {
import {logger} from '../utils/logger.js';
import {ToolCategory} from './categories.js';
import type {Context, Response, ContextPage} from './ToolDefinition.js';
import type {
Context,
Response,
ContextPage,
PageToolFactory,
} from './ToolDefinition.js';
import {definePageTool} from './ToolDefinition.js';
const filePathSchema = zod
@@ -25,7 +30,7 @@ const filePathSchema = zod
'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
);
export const startTrace = definePageTool(() => ({
export const startTrace: PageToolFactory = definePageTool(() => ({
name: 'performance_start_trace',
description: `Start a performance trace on the target webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed.`,
annotations: {
@@ -142,7 +147,7 @@ export const startTrace = definePageTool(() => ({
},
}));
export const stopTrace = definePageTool(() => ({
export const stopTrace: PageToolFactory = definePageTool(() => ({
name: 'performance_stop_trace',
description:
'Stop the active performance trace recording on the target webpage.',
@@ -175,7 +180,7 @@ export const stopTrace = definePageTool(() => ({
},
}));
export const analyzeInsight = definePageTool(() => ({
export const analyzeInsight: PageToolFactory = definePageTool(() => ({
name: 'performance_analyze_insight',
description:
'Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording.',
+5 -5
View File
@@ -7,7 +7,7 @@
import {zod} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {defineTool} from './ToolDefinition.js';
import {defineTool, type ToolFactory} from './ToolDefinition.js';
const manifestIdSchema = zod
.string()
@@ -28,7 +28,7 @@ const displayModeSchema = zod
'app-window experience.',
);
export const installPwa = defineTool(() => ({
export const installPwa: ToolFactory = defineTool(() => ({
name: 'install_pwa',
description:
'Installs a Progressive Web App (PWA) identified by its manifest ID. ' +
@@ -78,7 +78,7 @@ export const installPwa = defineTool(() => ({
},
}));
export const uninstallPwa = defineTool(() => ({
export const uninstallPwa: ToolFactory = defineTool(() => ({
name: 'uninstall_pwa',
description:
'Uninstalls a Progressive Web App identified by its manifest ID and ' +
@@ -103,7 +103,7 @@ export const uninstallPwa = defineTool(() => ({
},
}));
export const launchPwa = defineTool(() => ({
export const launchPwa: ToolFactory = defineTool(() => ({
name: 'launch_pwa',
description:
'Launches an installed Progressive Web App using its saved display mode. ' +
@@ -135,7 +135,7 @@ export const launchPwa = defineTool(() => ({
},
}));
export const getOsAppState = defineTool(() => ({
export const getOsAppState: ToolFactory = defineTool(() => ({
name: 'get_os_app_state',
description:
'Returns the OS integration state (badge count and registered file ' +
+94 -92
View File
@@ -18,7 +18,7 @@ import type {
} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
import {definePageTool, type PageToolFactory} from './ToolDefinition.js';
async function generateTempFilePath(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chrome-devtools-mcp-'));
@@ -70,104 +70,106 @@ async function getPagePixelSize(
};
}
export const startScreencast = definePageTool((args: ParsedArguments) => ({
name: 'screencast_start',
description: `Starts recording a screencast (video) of the target page.`,
annotations: {
category: ToolCategory.DEBUGGING,
readOnlyHint: false,
},
schema: {
filePath: zod
.string()
.optional()
.describe(
args?.experimentalScreencast
? `Output file path (${supportedExtensions.join(',')} are supported). Uses mkdtemp to generate a unique path if not provided.`
: 'Output file path (.mp4). Uses mkdtemp to generate a unique path if not provided.',
),
},
blockedByDialog: false,
concurrency: 'exclusive',
verifyFilesSchema: {
filePath: true,
},
handler: async (request, response, context) => {
const page = request.page;
if (page.getScreenRecorder() !== null) {
response.appendResponseLine(
'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.',
);
return;
}
const requestedFilePath = request.params.filePath;
const filePath = requestedFilePath ?? (await generateTempFilePath());
// The built-in Chrome recorder (page.record) always produces mp4. The
// legacy ffmpeg recorder (page.screencast) also supports WebM.
const useLegacy = Boolean(args?.experimentalScreencast);
const extension = useLegacy ? getLegacyExtension(filePath) : '.mp4';
const resolvedPath = await context.ensureExtension(filePath, extension);
let recorder: ScreenRecorder | ScreenRecording;
try {
if (useLegacy) {
const format: VideoFormat = extension === '.webm' ? 'webm' : 'mp4';
recorder = await page.pptrPage.screencast({
path: resolvedPath,
format,
ffmpegPath: args?.experimentalFfmpegPath,
fps: args?.screencastFps,
});
} else {
// Without explicit limits Chrome downscales frames to a small default
// size. Record at the actual device pixel size of the page instead.
const {maxWidth, maxHeight} = await getPagePixelSize(page.pptrPage);
recorder = await page.pptrPage.record({
path: resolvedPath,
fps: args?.screencastFps,
maxWidth,
maxHeight,
});
export const startScreencast: PageToolFactory = definePageTool(
(args: ParsedArguments) => ({
name: 'screencast_start',
description: `Starts recording a screencast (video) of the target page.`,
annotations: {
category: ToolCategory.DEBUGGING,
readOnlyHint: false,
},
schema: {
filePath: zod
.string()
.optional()
.describe(
args?.experimentalScreencast
? `Output file path (${supportedExtensions.join(',')} are supported). Uses mkdtemp to generate a unique path if not provided.`
: 'Output file path (.mp4). Uses mkdtemp to generate a unique path if not provided.',
),
},
blockedByDialog: false,
concurrency: 'exclusive',
verifyFilesSchema: {
filePath: true,
},
handler: async (request, response, context) => {
const page = request.page;
if (page.getScreenRecorder() !== null) {
response.appendResponseLine(
'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.',
);
return;
}
} catch (err) {
// If we generated a temporary directory for this recording, remove it so
// a failed start (e.g. ffmpeg missing) does not leak an empty directory.
if (requestedFilePath === undefined) {
try {
await fs.rm(path.dirname(resolvedPath), {
recursive: true,
force: true,
const requestedFilePath = request.params.filePath;
const filePath = requestedFilePath ?? (await generateTempFilePath());
// The built-in Chrome recorder (page.record) always produces mp4. The
// legacy ffmpeg recorder (page.screencast) also supports WebM.
const useLegacy = Boolean(args?.experimentalScreencast);
const extension = useLegacy ? getLegacyExtension(filePath) : '.mp4';
const resolvedPath = await context.ensureExtension(filePath, extension);
let recorder: ScreenRecorder | ScreenRecording;
try {
if (useLegacy) {
const format: VideoFormat = extension === '.webm' ? 'webm' : 'mp4';
recorder = await page.pptrPage.screencast({
path: resolvedPath,
format,
ffmpegPath: args?.experimentalFfmpegPath,
fps: args?.screencastFps,
});
} else {
// Without explicit limits Chrome downscales frames to a small default
// size. Record at the actual device pixel size of the page instead.
const {maxWidth, maxHeight} = await getPagePixelSize(page.pptrPage);
recorder = await page.pptrPage.record({
path: resolvedPath,
fps: args?.screencastFps,
maxWidth,
maxHeight,
});
} catch {
// no-op
}
} catch (err) {
// If we generated a temporary directory for this recording, remove it so
// a failed start (e.g. ffmpeg missing) does not leak an empty directory.
if (requestedFilePath === undefined) {
try {
await fs.rm(path.dirname(resolvedPath), {
recursive: true,
force: true,
});
} catch {
// no-op
}
}
const message = err instanceof Error ? err.message : String(err);
if (message.includes('ENOENT') && message.includes('ffmpeg')) {
throw new Error(
'ffmpeg is required for screencast recording but was not found. ' +
'Install ffmpeg (https://ffmpeg.org/) and ensure it is available in your PATH.',
);
}
if (message.includes('Page.startScreenRecording')) {
throw new Error(
'The connected Chrome does not support screen recording. Update Chrome to a newer version.',
);
}
throw err;
}
const message = err instanceof Error ? err.message : String(err);
if (message.includes('ENOENT') && message.includes('ffmpeg')) {
throw new Error(
'ffmpeg is required for screencast recording but was not found. ' +
'Install ffmpeg (https://ffmpeg.org/) and ensure it is available in your PATH.',
);
}
if (message.includes('Page.startScreenRecording')) {
throw new Error(
'The connected Chrome does not support screen recording. Update Chrome to a newer version.',
);
}
throw err;
}
page.setScreenRecorder({recorder, filePath: resolvedPath});
page.setScreenRecorder({recorder, filePath: resolvedPath});
response.appendResponseLine(
`Screencast recording started. The recording will be saved to ${resolvedPath}. Use ${stopScreencast(args).name} to stop recording.`,
);
},
}));
response.appendResponseLine(
`Screencast recording started. The recording will be saved to ${resolvedPath}. Use ${stopScreencast(args).name} to stop recording.`,
);
},
}),
);
export const stopScreencast = definePageTool(() => ({
export const stopScreencast: PageToolFactory = definePageTool(() => ({
name: 'screencast_stop',
description: 'Stops the active screencast recording on the target page.',
annotations: {
+165 -159
View File
@@ -14,7 +14,7 @@ import type {
} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
import {definePageTool, type PageToolFactory} from './ToolDefinition.js';
type ScreenshotFormat = 'png' | 'jpeg' | 'webp';
@@ -138,169 +138,175 @@ function computeDownscaleClip(
};
}
export const screenshot = definePageTool((args: ParsedArguments) => {
const {
screenshotFormat,
screenshotQuality,
screenshotMaxWidth,
screenshotMaxHeight,
} = args;
export const screenshot: PageToolFactory = definePageTool(
(args: ParsedArguments) => {
const {
screenshotFormat,
screenshotQuality,
screenshotMaxWidth,
screenshotMaxHeight,
} = args;
const defaultFormat: ScreenshotFormat = screenshotFormat ?? 'png';
const defaultFormat: ScreenshotFormat = screenshotFormat ?? 'png';
return {
name: 'take_screenshot',
description: `Take a screenshot of the page or element.`,
annotations: {
category: ToolCategory.DEBUGGING,
// Not read-only due to filePath param.
readOnlyHint: false,
},
schema: {
format: zod
.enum(['png', 'jpeg', 'webp'])
.default(defaultFormat)
.describe(
`Type of format to save the screenshot as. Default is "${defaultFormat}"`,
),
quality: zod
.number()
.min(0)
.max(100)
.optional()
.describe(
'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.',
),
uid: zod
.string()
.optional()
.describe(
'The uid of an element on the page from the page content snapshot. If omitted, takes a page screenshot.',
),
fullPage: zod
.boolean()
.optional()
.describe(
'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
),
filePath: zod
.string()
.optional()
.describe(
'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
),
},
blockedByDialog: true,
concurrency: 'page',
verifyFilesSchema: {
filePath: true,
},
handler: async (request, response, context) => {
if (request.params.uid && request.params.fullPage) {
throw new Error('Providing both "uid" and "fullPage" is not allowed.');
}
const page = request.page.pptrPage;
using element = request.params.uid
? await request.page.getElementByUid(request.params.uid)
: undefined;
const format = request.params.format;
const quality =
format === 'png'
? undefined
: (request.params.quality ?? screenshotQuality);
const fullPage = request.params.fullPage ?? false;
// `getElementByUid` mints a fresh ElementHandle per call, so dispose it
// once the capture is done to avoid leaking a remote object for the life
// of the page's execution context.
let screenshot: Uint8Array;
// Compute a downscale clip when --screenshot-max-width or
// --screenshot-max-height is set and the source exceeds either bound.
// The smaller scale factor wins so both bounds are respected while
// preserving aspect ratio.
let clip: ScreenshotClip | undefined;
if (
screenshotMaxWidth !== undefined ||
screenshotMaxHeight !== undefined
) {
const box = await getSourceBox(page, element, fullPage);
if (box) {
clip = computeDownscaleClip(
box,
screenshotMaxWidth,
screenshotMaxHeight,
return {
name: 'take_screenshot',
description: `Take a screenshot of the page or element.`,
annotations: {
category: ToolCategory.DEBUGGING,
// Not read-only due to filePath param.
readOnlyHint: false,
},
schema: {
format: zod
.enum(['png', 'jpeg', 'webp'])
.default(defaultFormat)
.describe(
`Type of format to save the screenshot as. Default is "${defaultFormat}"`,
),
quality: zod
.number()
.min(0)
.max(100)
.optional()
.describe(
'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.',
),
uid: zod
.string()
.optional()
.describe(
'The uid of an element on the page from the page content snapshot. If omitted, takes a page screenshot.',
),
fullPage: zod
.boolean()
.optional()
.describe(
'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
),
filePath: zod
.string()
.optional()
.describe(
'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
),
},
blockedByDialog: true,
concurrency: 'page',
verifyFilesSchema: {
filePath: true,
},
handler: async (request, response, context) => {
if (request.params.uid && request.params.fullPage) {
throw new Error(
'Providing both "uid" and "fullPage" is not allowed.',
);
}
}
if (clip) {
// page.screenshot with clip lets the CDP scale param downscale the
// capture for viewport, full-page and element shots alike. We rely on
// Puppeteer's default of captureBeyondViewport=true when a clip is
// present so element/full-page captures below the fold still work.
screenshot = await page.screenshot({
type: format,
quality,
optimizeForSpeed: true,
clip,
});
} else if (element) {
screenshot = await element.screenshot({
type: format,
quality,
optimizeForSpeed: true,
});
} else {
screenshot = await page.screenshot({
type: format,
fullPage,
quality,
optimizeForSpeed: true,
});
}
const page = request.page.pptrPage;
using element = request.params.uid
? await request.page.getElementByUid(request.params.uid)
: undefined;
if (request.params.uid) {
response.appendResponseLine(
`Took a screenshot of node with uid "${request.params.uid}".`,
);
} else if (fullPage) {
response.appendResponseLine(
'Took a screenshot of the full current page.',
);
} else {
response.appendResponseLine(
"Took a screenshot of the current page's viewport.",
);
}
const format = request.params.format;
const quality =
format === 'png'
? undefined
: (request.params.quality ?? screenshotQuality);
const fullPage = request.params.fullPage ?? false;
// Narrow `format` at the point of use: in the factory form of
// definePageTool TS widens the Schema generic, which loses the literal
// union from zod.enum on request.params.format.
const extension: '.png' | '.jpeg' | '.webp' =
format === 'jpeg' ? '.jpeg' : format === 'webp' ? '.webp' : '.png';
// `getElementByUid` mints a fresh ElementHandle per call, so dispose it
// once the capture is done to avoid leaking a remote object for the life
// of the page's execution context.
let screenshot: Uint8Array;
if (request.params.filePath) {
const result = await context.saveFile(
screenshot,
request.params.filePath,
extension,
);
response.appendResponseLine(`Saved screenshot to ${result.filename}.`);
} else if (screenshot.length >= 2_000_000) {
const {filepath} = await context.saveTemporaryFile(
screenshot,
`screenshot${extension}`,
);
response.appendResponseLine(`Saved screenshot to ${filepath}.`);
} else {
response.attachImage({
mimeType: `image/${format}`,
data: Buffer.from(screenshot).toString('base64'),
});
}
},
};
});
// Compute a downscale clip when --screenshot-max-width or
// --screenshot-max-height is set and the source exceeds either bound.
// The smaller scale factor wins so both bounds are respected while
// preserving aspect ratio.
let clip: ScreenshotClip | undefined;
if (
screenshotMaxWidth !== undefined ||
screenshotMaxHeight !== undefined
) {
const box = await getSourceBox(page, element, fullPage);
if (box) {
clip = computeDownscaleClip(
box,
screenshotMaxWidth,
screenshotMaxHeight,
);
}
}
if (clip) {
// page.screenshot with clip lets the CDP scale param downscale the
// capture for viewport, full-page and element shots alike. We rely on
// Puppeteer's default of captureBeyondViewport=true when a clip is
// present so element/full-page captures below the fold still work.
screenshot = await page.screenshot({
type: format,
quality,
optimizeForSpeed: true,
clip,
});
} else if (element) {
screenshot = await element.screenshot({
type: format,
quality,
optimizeForSpeed: true,
});
} else {
screenshot = await page.screenshot({
type: format,
fullPage,
quality,
optimizeForSpeed: true,
});
}
if (request.params.uid) {
response.appendResponseLine(
`Took a screenshot of node with uid "${request.params.uid}".`,
);
} else if (fullPage) {
response.appendResponseLine(
'Took a screenshot of the full current page.',
);
} else {
response.appendResponseLine(
"Took a screenshot of the current page's viewport.",
);
}
// Narrow `format` at the point of use: in the factory form of
// definePageTool TS widens the Schema generic, which loses the literal
// union from zod.enum on request.params.format.
const extension: '.png' | '.jpeg' | '.webp' =
format === 'jpeg' ? '.jpeg' : format === 'webp' ? '.webp' : '.png';
if (request.params.filePath) {
const result = await context.saveFile(
screenshot,
request.params.filePath,
extension,
);
response.appendResponseLine(
`Saved screenshot to ${result.filename}.`,
);
} else if (screenshot.length >= 2_000_000) {
const {filepath} = await context.saveTemporaryFile(
screenshot,
`screenshot${extension}`,
);
response.appendResponseLine(`Saved screenshot to ${filepath}.`);
} else {
response.attachImage({
mimeType: `image/${format}`,
data: Buffer.from(screenshot).toString('base64'),
});
}
},
};
},
);
+179 -170
View File
@@ -11,194 +11,203 @@ import {zod} from '../third_party/index.js';
import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import type {Context, Response} from './ToolDefinition.js';
import type {Context, Response, ToolFactory} from './ToolDefinition.js';
import {defineTool, pageIdSchema} from './ToolDefinition.js';
export type Evaluatable = Page | Frame | WebWorker;
export const evaluateScript = defineTool((cliArgs: ParsedArguments) => {
return {
name: 'evaluate_script',
description: `Evaluate JavaScript inside the target 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,
conditions: ['javascriptEvaluation'],
},
schema: {
...(cliArgs.pageIdRouting
? cliArgs.categoryExtensions
? {
pageId: zod
.number()
.optional()
.describe(
'Targets a specific page by ID. Required when not evaluating in a service worker.',
),
}
: pageIdSchema
: {}),
function: zod
.string()
.optional()
.describe(
`JavaScript source to execute in the target page. Provide either this or sourcePath, but not both. The source is interpreted according to format.
export const evaluateScript: ToolFactory = defineTool(
(cliArgs: ParsedArguments) => {
return {
name: 'evaluate_script',
description: `Evaluate JavaScript inside the target 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,
conditions: ['javascriptEvaluation'],
},
schema: {
...(cliArgs.pageIdRouting
? cliArgs.categoryExtensions
? {
pageId: zod
.number()
.optional()
.describe(
'Targets a specific page by ID. Required when not evaluating in a service worker.',
),
}
: pageIdSchema
: {}),
function: zod
.string()
.optional()
.describe(
`JavaScript source to execute in the target 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
.string()
.describe(
'The uid of an element on the page from the page content snapshot',
),
)
.optional()
.describe(
`An optional list of arguments (element uids from the page content snapshot) to pass to the function.`,
),
filePath: zod
.string()
.optional()
.describe(
'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.',
),
dialogAction: zod
.string()
.optional()
.describe(
'Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.',
),
waitForStableDom: zod
.boolean()
.optional()
.describe(
'Whether to wait for the DOM to settle. Pass false if the script only reads data. Defaults to true.',
),
...(cliArgs.categoryExtensions
? {
serviceWorkerId: zod
),
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
.string()
.optional()
.describe(
`The optional service worker id to evaluate the script in. If provided, 'pageId' should be omitted. Note: 'args' (element UIDs) cannot be used when evaluating in a service worker.`,
'The uid of an element on the page from the page content snapshot',
),
}
: {}),
},
blockedByDialog: false,
concurrency: 'exclusive',
verifyFilesSchema: {
filePath: true,
// sourcePath may be an http(s): URL the server fetches, so allow URLs
// through; a file: path or plain path is still validated.
sourcePath: {local: true, remote: true, allowUrl: true},
},
handler: async (request, response, context) => {
const {
serviceWorkerId,
args: uidArgs,
function: fnString,
sourcePath,
format = 'function',
pageId,
dialogAction,
filePath,
waitForStableDom,
} = request.params;
)
.optional()
.describe(
`An optional list of arguments (element uids from the page content snapshot) to pass to the function.`,
),
filePath: zod
.string()
.optional()
.describe(
'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.',
),
dialogAction: zod
.string()
.optional()
.describe(
'Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.',
),
waitForStableDom: zod
.boolean()
.optional()
.describe(
'Whether to wait for the DOM to settle. Pass false if the script only reads data. Defaults to true.',
),
...(cliArgs.categoryExtensions
? {
serviceWorkerId: zod
.string()
.optional()
.describe(
`The optional service worker id to evaluate the script in. If provided, 'pageId' should be omitted. Note: 'args' (element UIDs) cannot be used when evaluating in a service worker.`,
),
}
: {}),
},
blockedByDialog: false,
concurrency: 'exclusive',
verifyFilesSchema: {
filePath: true,
// sourcePath may be an http(s): URL the server fetches, so allow URLs
// through; a file: path or plain path is still validated.
sourcePath: {local: true, remote: true, allowUrl: 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, context);
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(
'args (element uids) cannot be used when evaluating in a service worker.',
);
const source = await resolveScriptSource(fnString, sourcePath, context);
if (format === 'script' && uidArgs && uidArgs.length > 0) {
throw new Error('args cannot be used when format is "script".');
}
if (pageId) {
if (cliArgs.categoryExtensions && serviceWorkerId) {
if (uidArgs && uidArgs.length > 0) {
throw new Error(
'args (element uids) cannot be used when evaluating in a service worker.',
);
}
if (pageId) {
throw new Error('specify either a pageId or a serviceWorkerId.');
}
const worker = await getWebWorker(context, serviceWorkerId);
const result = await context
.getSelectedMcpPage()
.waitForEventsAfterAction(
async () => {
await performEvaluation(worker, source, format, [], response, {
filePath,
context,
});
},
// Service workers cannot interact with the DOM, so never wait for it.
{handleDialog: dialogAction ?? 'accept', waitForStableDom: false},
);
if (result.dialogHandled) {
context.getSelectedMcpPage().clearDialog();
}
response.attachWaitForResult(result);
return;
}
if (cliArgs.categoryExtensions && cliArgs.pageIdRouting && !pageId) {
throw new Error('specify either a pageId or a serviceWorkerId.');
}
const worker = await getWebWorker(context, serviceWorkerId);
const result = await context
.getSelectedMcpPage()
.waitForEventsAfterAction(
async () => {
await performEvaluation(worker, source, format, [], response, {
const mcpPage =
cliArgs.pageIdRouting && request.params.pageId
? context.getPageById(request.params.pageId)
: context.getSelectedMcpPage();
await mcpPage.init({emulateFocus: true});
// evaluate_script is not page-scoped, so ToolHandler does not run the
// blockedByDialog check for it. Without this, an evaluation started
// while a dialog is open waits until the protocol timeout.
// TODO: include the "# Open dialog ... Call handle_dialog" hint once
// Response can attach the page for non-page-scoped tools.
mcpPage.throwIfDialogOpen();
const page: Page = mcpPage.pptrPage;
const args: Array<JSHandle<unknown>> = [];
using stack = new DisposableStack();
const frames = new Set<Frame>();
for (const uid of uidArgs ?? []) {
const handle = await mcpPage.getElementByUid(uid);
frames.add(handle.frame);
stack.use(handle);
args.push(handle);
}
const evaluatable = await getPageOrFrame(page, frames);
const result = await mcpPage.waitForEventsAfterAction(
async () => {
await performEvaluation(
evaluatable,
source,
format,
args,
response,
{
filePath,
context,
});
},
// Service workers cannot interact with the DOM, so never wait for it.
{handleDialog: dialogAction ?? 'accept', waitForStableDom: false},
);
if (result.dialogHandled) {
context.getSelectedMcpPage().clearDialog();
}
},
);
},
{handleDialog: dialogAction ?? 'accept', waitForStableDom},
);
response.attachWaitForResult(result);
return;
}
if (cliArgs.categoryExtensions && cliArgs.pageIdRouting && !pageId) {
throw new Error('specify either a pageId or a serviceWorkerId.');
}
const mcpPage =
cliArgs.pageIdRouting && request.params.pageId
? context.getPageById(request.params.pageId)
: context.getSelectedMcpPage();
await mcpPage.init({emulateFocus: true});
// evaluate_script is not page-scoped, so ToolHandler does not run the
// blockedByDialog check for it. Without this, an evaluation started
// while a dialog is open waits until the protocol timeout.
// TODO: include the "# Open dialog ... Call handle_dialog" hint once
// Response can attach the page for non-page-scoped tools.
mcpPage.throwIfDialogOpen();
const page: Page = mcpPage.pptrPage;
const args: Array<JSHandle<unknown>> = [];
using stack = new DisposableStack();
const frames = new Set<Frame>();
for (const uid of uidArgs ?? []) {
const handle = await mcpPage.getElementByUid(uid);
frames.add(handle.frame);
stack.use(handle);
args.push(handle);
}
const evaluatable = await getPageOrFrame(page, frames);
const result = await mcpPage.waitForEventsAfterAction(
async () => {
await performEvaluation(evaluatable, source, format, args, response, {
filePath,
context,
});
},
{handleDialog: dialogAction ?? 'accept', waitForStableDom},
);
response.attachWaitForResult(result);
},
};
});
},
};
},
);
const resolveScriptSource = async (
inlineSource: string | undefined,
+49 -47
View File
@@ -8,10 +8,10 @@ import type {ParsedArguments} from '../../config/ConfigParser.js';
import type {Dialog} from '../../third_party/index.js';
import {zod} from '../../third_party/index.js';
import {ToolCategory} from '../categories.js';
import {definePageTool} from '../ToolDefinition.js';
import {definePageTool, type PageToolFactory} from '../ToolDefinition.js';
import {validateUrl} from '../../utils/url.js';
export const screenshot = definePageTool(() => ({
export const screenshot: PageToolFactory = definePageTool(() => ({
name: 'screenshot',
description: `Takes a screenshot`,
annotations: {
@@ -38,56 +38,58 @@ export const screenshot = definePageTool(() => ({
},
}));
export const navigate = definePageTool((args: ParsedArguments) => {
return {
name: 'navigate',
description: `Loads a URL`,
annotations: {
category: ToolCategory.NAVIGATION,
readOnlyHint: false,
conditions: ['slim'],
},
schema: {
url: zod.string().describe('URL to navigate to'),
},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
validateUrl(request.params.url, {
javascriptEvaluation: args?.javascriptEvaluation,
categoryExtensions: args?.categoryExtensions,
fileNavigations: args?.fileNavigations,
});
export const navigate: PageToolFactory = definePageTool(
(args: ParsedArguments) => {
return {
name: 'navigate',
description: `Loads a URL`,
annotations: {
category: ToolCategory.NAVIGATION,
readOnlyHint: false,
conditions: ['slim'],
},
schema: {
url: zod.string().describe('URL to navigate to'),
},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
validateUrl(request.params.url, {
javascriptEvaluation: args?.javascriptEvaluation,
categoryExtensions: args?.categoryExtensions,
fileNavigations: args?.fileNavigations,
});
const page = request.page;
const page = request.page;
const options = {
timeout: 30_000,
};
const options = {
timeout: 30_000,
};
const dialogHandler = (dialog: Dialog): void => {
if (dialog.type() === 'beforeunload') {
response.appendResponseLine(`Accepted a beforeunload dialog.`);
void dialog.accept();
// We are not going to report the dialog like regular dialogs.
page.clearDialog();
const dialogHandler = (dialog: Dialog): void => {
if (dialog.type() === 'beforeunload') {
response.appendResponseLine(`Accepted a beforeunload dialog.`);
void dialog.accept();
// We are not going to report the dialog like regular dialogs.
page.clearDialog();
}
};
page.pptrPage.on('dialog', dialogHandler);
try {
await page.pptrPage.goto(request.params.url, options);
response.appendResponseLine(`Navigated to ${page.pptrPage.url()}.`);
} finally {
page.pptrPage.off('dialog', dialogHandler);
}
};
},
};
},
);
page.pptrPage.on('dialog', dialogHandler);
try {
await page.pptrPage.goto(request.params.url, options);
response.appendResponseLine(`Navigated to ${page.pptrPage.url()}.`);
} finally {
page.pptrPage.off('dialog', dialogHandler);
}
},
};
});
export const evaluate = definePageTool(() => ({
export const evaluate: PageToolFactory = definePageTool(() => ({
name: 'evaluate',
description: `Evaluates a JavaScript script`,
annotations: {
+7 -3
View File
@@ -7,9 +7,13 @@
import {zod} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool, timeoutSchema} from './ToolDefinition.js';
import {
definePageTool,
type PageToolFactory,
timeoutSchema,
} from './ToolDefinition.js';
export const takeSnapshot = definePageTool(() => ({
export const takeSnapshot: PageToolFactory = definePageTool(() => ({
name: 'take_snapshot',
description: `Take a text snapshot of the target page based on the a11y tree. The snapshot lists page elements along with a unique
identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected
@@ -46,7 +50,7 @@ in the DevTools Elements panel (if any).`,
},
}));
export const waitFor = definePageTool(() => ({
export const waitFor: PageToolFactory = definePageTool(() => ({
name: 'wait_for',
description: `Wait for the specified text to appear on the selected page. By default, hidden text is also matched. Set visible to true to wait until the matched text is visible.`,
annotations: {
+84 -80
View File
@@ -7,7 +7,7 @@
import {zod, ajv, type JSONSchema7} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
import {definePageTool, type PageToolFactory} from './ToolDefinition.js';
export interface ToolDefinition {
name: string;
@@ -43,92 +43,96 @@ declare global {
}
}
export const listThirdPartyDeveloperTools = definePageTool(() => ({
name: 'list_3p_developer_tools',
description: `Lists all third-party developer tools the page exposes for providing runtime information (including debugging WebMCP tools and tools exposed via the 'devtoolstooldiscovery' event).
export const listThirdPartyDeveloperTools: PageToolFactory = definePageTool(
() => ({
name: 'list_3p_developer_tools',
description: `Lists all third-party developer tools the page exposes for providing runtime information (including debugging WebMCP tools and tools exposed via the 'devtoolstooldiscovery' event).
Third-party developer tools can be called via the 'execute_3p_developer_tool()' MCP tool.
Alternatively, third-party developer tools exposed via the 'devtoolstooldiscovery' event can be executed by calling 'evaluate_script' and adding the
following command to the script:
\`window.__dtmcp.executeTool(toolName, params)\`
This might be helpful when the third-party developer tools return non-serializable values or when composing
third-party developer tools with additional functionality.`,
annotations: {
category: ToolCategory.THIRD_PARTY,
readOnlyHint: true,
},
schema: {},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (_request, response) => {
response.setListThirdPartyDeveloperTools();
},
}));
annotations: {
category: ToolCategory.THIRD_PARTY,
readOnlyHint: true,
},
schema: {},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (_request, response) => {
response.setListThirdPartyDeveloperTools();
},
}),
);
export const executeThirdPartyDeveloperTool = definePageTool(() => ({
name: 'execute_3p_developer_tool',
description: `Executes a third-party developer tool exposed by the page.`,
annotations: {
category: ToolCategory.THIRD_PARTY,
readOnlyHint: false,
},
schema: {
toolName: zod.string().describe('The name of the tool to execute'),
params: zod
.string()
.optional()
.describe('The JSON-stringified parameters to pass to the tool'),
},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
const toolName = request.params.toolName;
let params: Record<string, unknown> = {};
if (request.params.params) {
try {
const parsed = JSON.parse(request.params.params);
if (
typeof parsed === 'object' &&
parsed !== null &&
!Array.isArray(parsed)
) {
params = parsed;
} else {
throw new Error('Parsed params is not an object');
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
throw new Error(`Failed to parse params as JSON: ${errorMessage}`);
}
}
const toolGroups = await request.page.discoverThirdPartyDeveloperTools();
let tool;
if (toolGroups) {
for (const group of toolGroups) {
tool = group.tools?.find(t => t.name === toolName);
if (tool) {
break;
export const executeThirdPartyDeveloperTool: PageToolFactory = definePageTool(
() => ({
name: 'execute_3p_developer_tool',
description: `Executes a third-party developer tool exposed by the page.`,
annotations: {
category: ToolCategory.THIRD_PARTY,
readOnlyHint: false,
},
schema: {
toolName: zod.string().describe('The name of the tool to execute'),
params: zod
.string()
.optional()
.describe('The JSON-stringified parameters to pass to the tool'),
},
blockedByDialog: false,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
const toolName = request.params.toolName;
let params: Record<string, unknown> = {};
if (request.params.params) {
try {
const parsed = JSON.parse(request.params.params);
if (
typeof parsed === 'object' &&
parsed !== null &&
!Array.isArray(parsed)
) {
params = parsed;
} else {
throw new Error('Parsed params is not an object');
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
throw new Error(`Failed to parse params as JSON: ${errorMessage}`);
}
}
}
if (!tool) {
throw new Error(`Tool ${toolName} not found`);
}
const ajvInstance = new ajv();
const validate = ajvInstance.compile(tool.inputSchema);
const valid = validate(params);
if (!valid) {
throw new Error(
`Invalid parameters for tool ${toolName}: ${ajvInstance.errorsText(validate.errors)}`,
const toolGroups = await request.page.discoverThirdPartyDeveloperTools();
let tool;
if (toolGroups) {
for (const group of toolGroups) {
tool = group.tools?.find(t => t.name === toolName);
if (tool) {
break;
}
}
}
if (!tool) {
throw new Error(`Tool ${toolName} not found`);
}
const ajvInstance = new ajv();
const validate = ajvInstance.compile(tool.inputSchema);
const valid = validate(params);
if (!valid) {
throw new Error(
`Invalid parameters for tool ${toolName}: ${ajvInstance.errorsText(validate.errors)}`,
);
}
await request.page.executeThirdPartyDeveloperTool(
toolName,
params,
response,
);
}
await request.page.executeThirdPartyDeveloperTool(
toolName,
params,
response,
);
},
}));
},
}),
);
+3 -3
View File
@@ -7,9 +7,9 @@
import {zod} from '../third_party/index.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
import {definePageTool, type PageToolFactory} from './ToolDefinition.js';
export const listWebMcpTools = definePageTool(() => ({
export const listWebMcpTools: PageToolFactory = definePageTool(() => ({
name: 'list_webmcp_tools',
description: `Lists all WebMCP tools the page exposes (excluding debugging tools, which are exposed via 'list_3p_developer_tools').`,
annotations: {
@@ -25,7 +25,7 @@ export const listWebMcpTools = definePageTool(() => ({
},
}));
export const executeWebMcpTool = definePageTool(() => ({
export const executeWebMcpTool: PageToolFactory = definePageTool(() => ({
name: 'execute_webmcp_tool',
description: `Executes a WebMCP tool exposed by the page (for debugging tools, use 'execute_3p_developer_tool').`,
annotations: {
+1 -1
View File
@@ -2070,7 +2070,7 @@ describe('ToolHandler', () => {
describe('update', () => {
function createGlobalTool(
schema: zod.ZodRawShape,
schema: ToolDefinition['schema'],
handler: ToolDefinition['handler'],
options: {
description?: string;
+3 -2
View File
@@ -13,6 +13,7 @@ import sinon from 'sinon';
import type {ParsedArguments} from '../../src/config/ConfigParser.js';
import {TextSnapshot} from '../../src/TextSnapshot.js';
import {zod} from '../../src/third_party/index.js';
import {screenshot} from '../../src/tools/screenshot.js';
import {resolveCanonicalPath} from '../../src/utils/files.js';
import {createHandlerMocks, createMockElementHandle} from '../mocks.js';
@@ -511,7 +512,7 @@ describe('screenshot', () => {
// No explicit format passed: zod should apply the CLI-driven default.
await tool.handler(
{
params: {format: tool.schema.format.parse(undefined)},
params: {format: zod.object(tool.schema).parse({}).format},
page: context.getSelectedMcpPage(),
},
response,
@@ -525,7 +526,7 @@ describe('screenshot', () => {
it('keeps "png" as default format when no CLI override is set', async () => {
const tool = screenshot({} as ParsedArguments);
assert.equal(tool.schema.format.parse(undefined), 'png');
assert.equal(zod.object(tool.schema).parse({}).format, 'png');
});
it('downscales viewport screenshot when screenshotMaxWidth is set', async () => {