diff --git a/README.md b/README.md index 80892ae7c..b94217b2b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,21 @@ Add the following config to your MCP client: > [!NOTE] > Using `chrome-devtools-mcp@latest` ensures that your MCP client will always use the latest version of the Chrome DevTools MCP server. +If you are intersted in doing only basic browser tasks, use the `--slim` mode: + +```json +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@latest", "--slim", "--headless"] + } + } +} +``` + +See [Slim tool reference](./docs/slim-tool-reference.md). + ### MCP Client configuration
@@ -532,6 +547,10 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** boolean - **Default:** `true` +- **`--slim`** + Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks. + - **Type:** boolean + Pass them via the `args` property in the JSON configuration. For example: diff --git a/docs/slim-tool-reference.md b/docs/slim-tool-reference.md new file mode 100644 index 000000000..b173ebf66 --- /dev/null +++ b/docs/slim-tool-reference.md @@ -0,0 +1,41 @@ + + +# Chrome DevTools MCP Slim Tool Reference (~368 cl100k_base tokens) + +- **[Navigation automation](#navigation-automation)** (1 tools) + - [`navigate`](#navigate) +- **[Debugging](#debugging)** (2 tools) + - [`evaluate`](#evaluate) + - [`screenshot`](#screenshot) + +## Navigation automation + +### `navigate` + +**Description:** Load URL in the browser + +**Parameters:** + +- **url** (string) **(required)**: Page URL + +--- + +## Debugging + +### `evaluate` + +**Description:** [`Evaluate`](#evaluate) a JavaScript function on the last loaded page + +**Parameters:** + +- **fn** (string) **(required)**: A JavaScript function to be executed on the active page + +--- + +### `screenshot` + +**Description:** Take a [`screenshot`](#screenshot) of the active page. + +**Parameters:** None + +--- diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 691e0edc2..31f2d0997 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -13,16 +13,18 @@ import {get_encoding} from 'tiktoken'; import {cliOptions} from '../build/src/cli.js'; import {ToolCategory, labels} from '../build/src/tools/categories.js'; +import {tools as slimTools} from '../build/src/tools/slim/tools.js'; import {tools} from '../build/src/tools/tools.js'; const OUTPUT_PATH = './docs/tool-reference.md'; +const SLIM_OUTPUT_PATH = './docs/slim-tool-reference.md'; const README_PATH = './README.md'; -async function measureServer() { +async function measureServer(args: string[]) { // 1. Connect to your actual MCP server const transport = new StdioClientTransport({ command: 'node', - args: ['./build/src/index.js'], // Point to your built MCP server + args: ['./build/src/index.js', ...args], // Point to your built MCP server }); const client = new Client( @@ -310,190 +312,225 @@ function isRequired(schema: ZodSchema): boolean { return def.typeName !== 'ZodOptional' && def.typeName !== 'ZodDefault'; } -async function generateToolDocumentation(): Promise { - try { - console.log('Generating tool documentation from definitions...'); +async function generateReference( + title: string, + outputPath: string, + toolsWithAnnotations: ToolWithAnnotations[], + categories: Record, + sortedCategories: string[], + serverArgs: string[], +) { + console.log(`Found ${toolsWithAnnotations.length} tools`); - // Convert ToolDefinitions to ToolWithAnnotations - const toolsWithAnnotations: ToolWithAnnotations[] = tools - .filter(tool => { - if (!tool.annotations.conditions) { - return true; - } + // Generate markdown documentation + let markdown = ` - // Only include unconditional tools. - return tool.annotations.conditions.length === 0; - }) - .map(tool => { - const properties: Record = {}; - const required: string[] = []; - - for (const [key, schema] of Object.entries( - tool.schema as unknown as Record, - )) { - const info = getZodTypeInfo(schema); - properties[key] = info; - if (isRequired(schema)) { - required.push(key); - } - } +# ${title} (~${(await measureServer(serverArgs)).tokenCount} cl100k_base tokens) - return { - name: tool.name, - description: tool.description, - inputSchema: { - type: 'object', - properties, - required, - }, - annotations: tool.annotations, - }; - }); +`; + // Generate table of contents + for (const category of sortedCategories) { + const categoryTools = categories[category]; + const categoryName = labels[category]; + const anchorName = categoryName.toLowerCase().replace(/\s+/g, '-'); + markdown += `- **[${categoryName}](#${anchorName})** (${categoryTools.length} tools)\n`; - console.log(`Found ${toolsWithAnnotations.length} tools`); + // Sort tools within category for TOC + categoryTools.sort((a: Tool, b: Tool) => a.name.localeCompare(b.name)); + for (const tool of categoryTools) { + // Generate proper markdown anchor link: backticks are removed, keep underscores, lowercase + const anchorLink = tool.name.toLowerCase(); + markdown += ` - [\`${tool.name}\`](#${anchorLink})\n`; + } + } + markdown += '\n'; - // Generate markdown documentation - let markdown = ` + for (const category of sortedCategories) { + const categoryTools = categories[category]; + const categoryName = labels[category]; -# Chrome DevTools MCP Tool Reference (~${(await measureServer()).tokenCount} cl100k_base tokens) + markdown += `## ${categoryName}\n\n`; -`; + // Sort tools within category + categoryTools.sort((a: Tool, b: Tool) => a.name.localeCompare(b.name)); - // Group tools by category (based on annotations) - const categories: Record = {}; - toolsWithAnnotations.forEach((tool: ToolWithAnnotations) => { - const category = tool.annotations?.category || 'Uncategorized'; - if (!categories[category]) { - categories[category] = []; + for (const tool of categoryTools) { + markdown += `### \`${tool.name}\`\n\n`; + + if (tool.description) { + // Escape HTML tags but preserve JS function syntax + let escapedDescription = escapeHtmlTags(tool.description); + + // Add cross-links to mentioned tools + escapedDescription = addCrossLinks( + escapedDescription, + toolsWithAnnotations, + ); + markdown += `**Description:** ${escapedDescription}\n\n`; } - categories[category].push(tool); - }); - // Sort categories using the enum order - const categoryOrder = Object.values(ToolCategory); - const sortedCategories = Object.keys(categories).sort((a, b) => { - const aIndex = categoryOrder.indexOf(a); - const bIndex = categoryOrder.indexOf(b); - // Put known categories first, unknown categories last - if (aIndex === -1 && bIndex === -1) { - return a.localeCompare(b); - } - if (aIndex === -1) { - return 1; - } - if (bIndex === -1) { - return -1; - } - return aIndex - bIndex; - }); + // Handle input schema + if ( + tool.inputSchema && + tool.inputSchema.properties && + Object.keys(tool.inputSchema.properties).length > 0 + ) { + const properties = tool.inputSchema.properties; + const required = tool.inputSchema.required || []; + + markdown += '**Parameters:**\n\n'; + + const propertyNames = Object.keys(properties).sort((a, b) => { + const aRequired = required.includes(a); + const bRequired = required.includes(b); + if (aRequired && !bRequired) { + return -1; + } + if (!aRequired && bRequired) { + return 1; + } + return a.localeCompare(b); + }); + for (const propName of propertyNames) { + const prop = properties[propName] as TypeInfo; + const isRequired = required.includes(propName); + const requiredText = isRequired ? ' **(required)**' : ' _(optional)_'; + + let typeInfo = prop.type || 'unknown'; + if (prop.enum) { + typeInfo = `enum: ${prop.enum.map((v: string) => `"${v}"`).join(', ')}`; + } - // Generate table of contents - for (const category of sortedCategories) { - const categoryTools = categories[category]; - const categoryName = labels[category]; - const anchorName = categoryName.toLowerCase().replace(/\s+/g, '-'); - markdown += `- **[${categoryName}](#${anchorName})** (${categoryTools.length} tools)\n`; - - // Sort tools within category for TOC - categoryTools.sort((a: Tool, b: Tool) => a.name.localeCompare(b.name)); - for (const tool of categoryTools) { - // Generate proper markdown anchor link: backticks are removed, keep underscores, lowercase - const anchorLink = tool.name.toLowerCase(); - markdown += ` - [\`${tool.name}\`](#${anchorLink})\n`; - } - } - markdown += '\n'; + markdown += `- **${propName}** (${typeInfo})${requiredText}`; + if (prop.description) { + let escapedParamDesc = escapeHtmlTags(prop.description); - for (const category of sortedCategories) { - const categoryTools = categories[category]; - const categoryName = labels[category]; + // Add cross-links to mentioned tools + escapedParamDesc = addCrossLinks( + escapedParamDesc, + toolsWithAnnotations, + ); + markdown += `: ${escapedParamDesc}`; + } + markdown += '\n'; + } + markdown += '\n'; + } else { + markdown += '**Parameters:** None\n\n'; + } - markdown += `## ${categoryName}\n\n`; + markdown += '---\n\n'; + } + } - // Sort tools within category - categoryTools.sort((a: Tool, b: Tool) => a.name.localeCompare(b.name)); + // Write the documentation to file + fs.writeFileSync(outputPath, markdown.trim() + '\n'); - for (const tool of categoryTools) { - markdown += `### \`${tool.name}\`\n\n`; + console.log( + `Generated documentation for ${toolsWithAnnotations.length} tools in ${outputPath}`, + ); +} - if (tool.description) { - // Escape HTML tags but preserve JS function syntax - let escapedDescription = escapeHtmlTags(tool.description); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getToolsAndCategories(tools: any) { + // Convert ToolDefinitions to ToolWithAnnotations + const toolsWithAnnotations: ToolWithAnnotations[] = tools + .filter(tool => { + if (!tool.annotations.conditions) { + return true; + } - // Add cross-links to mentioned tools - escapedDescription = addCrossLinks( - escapedDescription, - toolsWithAnnotations, - ); - markdown += `**Description:** ${escapedDescription}\n\n`; + // Only include unconditional tools. + return tool.annotations.conditions.length === 0; + }) + .map(tool => { + const properties: Record = {}; + const required: string[] = []; + + for (const [key, schema] of Object.entries( + tool.schema as unknown as Record, + )) { + const info = getZodTypeInfo(schema); + properties[key] = info; + if (isRequired(schema)) { + required.push(key); } + } - // Handle input schema - if ( - tool.inputSchema && - tool.inputSchema.properties && - Object.keys(tool.inputSchema.properties).length > 0 - ) { - const properties = tool.inputSchema.properties; - const required = tool.inputSchema.required || []; - - markdown += '**Parameters:**\n\n'; - - const propertyNames = Object.keys(properties).sort((a, b) => { - const aRequired = required.includes(a); - const bRequired = required.includes(b); - if (aRequired && !bRequired) { - return -1; - } - if (!aRequired && bRequired) { - return 1; - } - return a.localeCompare(b); - }); - for (const propName of propertyNames) { - const prop = properties[propName] as TypeInfo; - const isRequired = required.includes(propName); - const requiredText = isRequired - ? ' **(required)**' - : ' _(optional)_'; - - let typeInfo = prop.type || 'unknown'; - if (prop.enum) { - typeInfo = `enum: ${prop.enum.map((v: string) => `"${v}"`).join(', ')}`; - } - - markdown += `- **${propName}** (${typeInfo})${requiredText}`; - if (prop.description) { - let escapedParamDesc = escapeHtmlTags(prop.description); - - // Add cross-links to mentioned tools - escapedParamDesc = addCrossLinks( - escapedParamDesc, - toolsWithAnnotations, - ); - markdown += `: ${escapedParamDesc}`; - } - markdown += '\n'; - } - markdown += '\n'; - } else { - markdown += '**Parameters:** None\n\n'; - } + return { + name: tool.name, + description: tool.description, + inputSchema: { + type: 'object', + properties, + required, + }, + annotations: tool.annotations, + }; + }); + // Group tools by category (based on annotations) + const categories: Record = {}; + toolsWithAnnotations.forEach((tool: ToolWithAnnotations) => { + const category = tool.annotations?.category || 'Uncategorized'; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(tool); + }); - markdown += '---\n\n'; - } + // Sort categories using the enum order + const categoryOrder = Object.values(ToolCategory); + const sortedCategories = Object.keys(categories).sort((a, b) => { + const aIndex = categoryOrder.indexOf(a); + const bIndex = categoryOrder.indexOf(b); + // Put known categories first, unknown categories last + if (aIndex === -1 && bIndex === -1) { + return a.localeCompare(b); + } + if (aIndex === -1) { + return 1; + } + if (bIndex === -1) { + return -1; } + return aIndex - bIndex; + }); + return {toolsWithAnnotations, categories, sortedCategories}; +} - // Write the documentation to file - fs.writeFileSync(OUTPUT_PATH, markdown.trim() + '\n'); +async function generateToolDocumentation(): Promise { + try { + console.log('Generating tool documentation from definitions...'); - console.log( - `Generated documentation for ${toolsWithAnnotations.length} tools in ${OUTPUT_PATH}`, - ); + { + const {toolsWithAnnotations, categories, sortedCategories} = + getToolsAndCategories(tools); + await generateReference( + 'Chrome DevTools MCP Tool Reference', + OUTPUT_PATH, + toolsWithAnnotations, + categories, + sortedCategories, + [], + ); + + // Generate tools TOC and update README + const toolsTOC = generateToolsTOC(categories, sortedCategories); + updateReadmeWithToolsTOC(toolsTOC); + } - // Generate tools TOC and update README - const toolsTOC = generateToolsTOC(categories, sortedCategories); - updateReadmeWithToolsTOC(toolsTOC); + { + const {toolsWithAnnotations, categories, sortedCategories} = + getToolsAndCategories(slimTools); + await generateReference( + 'Chrome DevTools MCP Slim Tool Reference', + SLIM_OUTPUT_PATH, + toolsWithAnnotations, + categories, + sortedCategories, + ['--slim'], + ); + } // Generate and update configuration options const optionsMarkdown = generateConfigOptionsMarkdown(); diff --git a/skills/chrome-devtools/SKILL.md b/skills/chrome-devtools/SKILL.md index 597a807e8..9b9fbce46 100644 --- a/skills/chrome-devtools/SKILL.md +++ b/skills/chrome-devtools/SKILL.md @@ -1,6 +1,6 @@ --- name: chrome-devtools -description: Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. +description: Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. This skill does not apply to `--slim` mode (MCP configuration). --- ## Core Concepts diff --git a/src/SlimMcpResponse.ts b/src/SlimMcpResponse.ts new file mode 100644 index 000000000..57198dd31 --- /dev/null +++ b/src/SlimMcpResponse.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + TextContent, + ImageContent, +} from '@modelcontextprotocol/sdk/types.js'; + +import type {McpContext} from './McpContext.js'; +import {McpResponse} from './McpResponse.js'; + +export class SlimMcpResponse extends McpResponse { + override async handle( + _toolName: string, + _context: McpContext, + ): Promise<{ + content: Array; + structuredContent: object; + }> { + const text: TextContent = { + type: 'text', + text: this.responseLines.join('\n'), + }; + return { + content: [text], + structuredContent: text, + }; + } +} diff --git a/src/cli.ts b/src/cli.ts index 7ecfab097..6019d816f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -236,6 +236,11 @@ export const cliOptions = { hidden: true, describe: 'Include watchdog PID in Clearcut request headers (for testing).', }, + slim: { + type: 'boolean', + describe: + 'Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks.', + }, } satisfies Record; export function parseArguments(version: string, argv = process.argv) { @@ -312,6 +317,10 @@ export function parseArguments(version: string, argv = process.argv) { '$0 --no-performance-crux', 'Disable CrUX (field data) integration in performance tools.', ], + [ + '$0 --slim', + 'Only 3 tools: navigation, JavaScript execution and screenshot', + ], ]); return yargsInstance diff --git a/src/main.ts b/src/main.ts index d6593b549..4c1fce50c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import {logger, saveLogsToFile} from './logger.js'; import {McpContext} from './McpContext.js'; import {McpResponse} from './McpResponse.js'; import {Mutex} from './Mutex.js'; +import {SlimMcpResponse} from './SlimMcpResponse.js'; import {ClearcutLogger} from './telemetry/ClearcutLogger.js'; import {computeFlagUsage} from './telemetry/flagUtils.js'; import {bucketizeLatency} from './telemetry/metricUtils.js'; @@ -26,6 +27,7 @@ import { SetLevelRequestSchema, } from './third_party/index.js'; import {ToolCategory} from './tools/categories.js'; +import {tools as slimTools} from './tools/slim/tools.js'; import type {ToolDefinition} from './tools/ToolDefinition.js'; import {tools} from './tools/tools.js'; @@ -130,13 +132,13 @@ debug, and modify any data in the browser or DevTools. Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, ); - if (args.performanceCrux) { + if (!args.slim && args.performanceCrux) { console.error( `Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`, ); } - if (args.usageStatistics) { + if (!args.slim && args.usageStatistics) { console.error( ` Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics. @@ -206,7 +208,7 @@ function registerTool(tool: ToolDefinition): void { const context = await getContext(); logger(`${tool.name} context: resolved`); await context.detectOpenDevToolsWindows(); - const response = new McpResponse(); + const response = args.slim ? new SlimMcpResponse() : new McpResponse(); await tool.handler( { params, @@ -258,7 +260,7 @@ function registerTool(tool: ToolDefinition): void { ); } -for (const tool of tools) { +for (const tool of args.slim ? slimTools : tools) { registerTool(tool); } diff --git a/src/tools/slim/tools.ts b/src/tools/slim/tools.ts new file mode 100644 index 000000000..7b83a044b --- /dev/null +++ b/src/tools/slim/tools.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Dialog} from '../../third_party/index.js'; +import {zod} from '../../third_party/index.js'; +import {ToolCategory} from '../categories.js'; +import type {ToolDefinition} from '../ToolDefinition.js'; +import {defineTool} from '../ToolDefinition.js'; + +export const screenshot = defineTool({ + name: 'screenshot', + description: `Take a screenshot of the active page.`, + annotations: { + category: ToolCategory.DEBUGGING, + // Not read-only due to filePath param. + readOnlyHint: false, + }, + schema: {}, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const screenshot = await page.screenshot({ + type: 'png', + optimizeForSpeed: true, + }); + const {filename} = await context.saveTemporaryFile(screenshot, `image/png`); + response.appendResponseLine(filename); + }, +}); + +export const navigate = defineTool({ + name: 'navigate', + description: `Load URL in the browser`, + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: false, + }, + schema: { + url: zod.string().describe('Page URL'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const options = { + timeout: 30_000, + }; + + const dialogHandler = (dialog: Dialog) => { + if (dialog.type() === 'beforeunload') { + response.appendResponseLine(`Accepted a beforeunload dialog.`); + void dialog.accept(); + // We are not going to report the dialog like regular dialogs. + context.clearDialog(); + } + }; + + page.on('dialog', dialogHandler); + + try { + await page.goto(request.params.url, options); + response.appendResponseLine(`Navigated to ${page.url()}.`); + } finally { + page.off('dialog', dialogHandler); + } + }, +}); + +export const evaluate = defineTool({ + name: 'evaluate', + description: `Evaluate a JavaScript function on the last loaded page`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: { + fn: zod + .string() + .describe(`A JavaScript function to be executed on the active page`), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const fn = await page.evaluateHandle(`(${request.params.fn})`); + try { + const result = await page.evaluate(async fn => { + // @ts-expect-error no types. + return JSON.stringify(await fn()); + }, fn); + response.appendResponseLine(result); + } catch (err) { + response.appendResponseLine(String(err.message)); + } finally { + void fn.dispose(); + } + }, +}); + +export const tools = [screenshot, evaluate, navigate] as ToolDefinition[]; diff --git a/tests/index.test.ts b/tests/index.test.ts index 5b3f7a54e..dc653cd84 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -92,7 +92,7 @@ describe('e2e', () => { const files = fs.readdirSync('build/src/tools'); const definedNames = []; for (const file of files) { - if (file === 'ToolDefinition.js') { + if (file === 'ToolDefinition.js' || file === 'slim') { continue; } const fileTools = await import(`../src/tools/${file}`); diff --git a/tests/tools/slim/tools.test.js.snapshot b/tests/tools/slim/tools.test.js.snapshot new file mode 100644 index 000000000..7e1d12b19 --- /dev/null +++ b/tests/tools/slim/tools.test.js.snapshot @@ -0,0 +1,11 @@ +exports[`slim > evaluates 1`] = ` +10 +`; + +exports[`slim > handles errors 1`] = ` +test error +`; + +exports[`slim > navigates to correct page 1`] = ` +Navigated to data:text/html,
Hello MCP
. +`; diff --git a/tests/tools/slim/tools.test.ts b/tests/tools/slim/tools.test.ts new file mode 100644 index 000000000..610350b87 --- /dev/null +++ b/tests/tools/slim/tools.test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, it} from 'node:test'; + +import {evaluate, navigate, screenshot} from '../../../src/tools/slim/tools.js'; +import {screenshots} from '../../snapshot.js'; +import {withMcpContext} from '../../utils.js'; + +describe('slim', () => { + it('evaluates', async t => { + await withMcpContext(async (response, context) => { + await evaluate.handler( + {params: {fn: String(() => 2 * 5)}}, + response, + context, + ); + t.assert.snapshot?.(response.responseLines.join('\n')); + }); + }); + + it('handles errors', async t => { + await withMcpContext(async (response, context) => { + await evaluate.handler( + { + params: { + fn: String(() => { + throw new Error('test error'); + }), + }, + }, + response, + context, + ); + t.assert.snapshot?.(response.responseLines.join('\n')); + }); + }); + + it('navigates to correct page', async t => { + await withMcpContext(async (response, context) => { + await navigate.handler( + {params: {url: 'data:text/html,
Hello MCP
'}}, + response, + context, + ); + const page = context.getSelectedPage(); + assert.equal( + await page.evaluate(() => document.querySelector('div')?.textContent), + 'Hello MCP', + ); + assert(!response.includePages); + t.assert.snapshot?.(response.responseLines.join('\n')); + }); + }); + + it('with default options', async () => { + await withMcpContext(async (response, context) => { + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await screenshot.handler({params: {format: 'png'}}, response, context); + assert(path.isAbsolute(response.responseLines.at(0)!)); + assert(fs.existsSync(response.responseLines.at(0)!)); + }); + }); +});