From 36275b827d3d0fc2d63eade6ac8b546ddba09a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Br=C3=A4nstr=C3=B6m?= Date: Sun, 25 Jan 2026 23:51:33 +0100 Subject: [PATCH 1/5] feat: add token optimization infrastructure Inspired by fast-playwright-mcp, this adds foundation for token-efficient responses: - Add expectation.ts with schema for controlling response content - includeSnapshot, includeConsole, includeNetwork, includeTabs - snapshotOptions (selector, maxLength, verbose) - imageOptions (quality, maxWidth, maxHeight, format) - Tool-specific defaults for optimal token usage - Add maxLength truncation to SnapshotFormatter - Truncates snapshot output with notice when limit exceeded - Useful for token efficiency on large pages - Expose maxLength parameter in take_snapshot tool - Users can now limit snapshot size directly This is Phase 1 of token optimization - the infrastructure layer. Future phases can integrate the expectation schema into tool handlers and add image processing utilities. Co-Authored-By: Claude Opus 4.5 --- src/McpResponse.ts | 4 +- src/expectation.ts | 338 ++++++++++++++++++++++++++++ src/formatters/SnapshotFormatter.ts | 24 +- src/tools/ToolDefinition.ts | 5 + src/tools/snapshot.ts | 7 + 5 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 src/expectation.ts diff --git a/src/McpResponse.ts b/src/McpResponse.ts index d4f538a62..9dcf967e4 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -234,7 +234,9 @@ export class McpResponse implements Response { ); const textSnapshot = context.getTextSnapshot(); if (textSnapshot) { - const formatter = new SnapshotFormatter(textSnapshot); + const formatter = new SnapshotFormatter(textSnapshot, { + maxLength: this.#snapshotParams.maxLength, + }); if (this.#snapshotParams.filePath) { await context.saveFile( new TextEncoder().encode(formatter.toString()), diff --git a/src/expectation.ts b/src/expectation.ts new file mode 100644 index 000000000..4b59d44a5 --- /dev/null +++ b/src/expectation.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Token optimization via expectation parameters. + * Inspired by fast-playwright-mcp. + */ + +import {zod} from './third_party/index.js'; + +/** + * Schema for snapshot filtering options. + * Allows limiting snapshot scope for token efficiency. + */ +export const snapshotOptionsSchema = zod + .object({ + selector: zod + .string() + .optional() + .describe('CSS selector to limit snapshot scope (e.g., ".main-content", "form")'), + maxLength: zod + .number() + .optional() + .describe('Maximum snapshot characters (truncates if exceeded)'), + verbose: zod + .boolean() + .optional() + .default(false) + .describe('Include verbose accessibility info'), + }) + .optional(); + +/** + * Schema for image compression options. + */ +export const imageOptionsSchema = zod + .object({ + quality: zod + .number() + .min(1) + .max(100) + .optional() + .describe('JPEG/WebP quality (1-100, lower = smaller)'), + maxWidth: zod + .number() + .optional() + .describe('Maximum width in pixels (resize if larger)'), + maxHeight: zod + .number() + .optional() + .describe('Maximum height in pixels (resize if larger)'), + format: zod + .enum(['jpeg', 'png', 'webp']) + .optional() + .describe('Image format (jpeg for smallest size)'), + }) + .optional(); + +/** + * Schema for expectation configuration that controls response content. + * All options default to false for maximum token efficiency. + */ +export const expectationSchema = zod.object({ + includeSnapshot: zod + .boolean() + .optional() + .default(false) + .describe('Include accessibility tree snapshot (false saves ~40% tokens)'), + includeConsole: zod + .boolean() + .optional() + .default(false) + .describe('Include console messages'), + includeNetwork: zod + .boolean() + .optional() + .default(false) + .describe('Include network requests'), + includeTabs: zod + .boolean() + .optional() + .default(false) + .describe('Include tab/page information'), + snapshotOptions: snapshotOptionsSchema, + imageOptions: imageOptionsSchema, +}).optional(); + +export type ExpectationOptions = zod.infer; +export type SnapshotOptions = zod.infer; +export type ImageOptions = zod.infer; + +/** + * Tool-specific default expectation configurations. + * These optimize token usage based on typical tool usage patterns. + * Tool names match chrome-devtools-mcp's actual tool names. + */ +type RequiredExpectationBase = Required< + Omit, 'imageOptions' | 'snapshotOptions'> +>; + +const TOOL_DEFAULTS: Record = { + // Navigation tools - minimal output by default + navigate_page: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + new_page: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + + // Input tools - minimal output + click: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + click_at: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + fill: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + fill_form: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + hover: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + drag: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + scroll: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + press_key: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + upload_file: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + + // Screenshot - minimal text, focus on image + take_screenshot: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + + // Snapshot tool - must include snapshot + get_page_content: { + includeSnapshot: true, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + + // Console tool - must include console + get_console_messages: { + includeSnapshot: false, + includeConsole: true, + includeNetwork: false, + includeTabs: false, + }, + + // Network tool - must include network + get_network_requests: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: true, + includeTabs: false, + }, + + // Tab management - include tabs + list_pages: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: true, + }, + select_page: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: true, + }, + close_page: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: true, + }, + resize_page: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: true, + }, + + // Dialog handling + handle_dialog: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: true, + }, + + // Script evaluation - minimal output + evaluate: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + wait_for_text: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + + // Performance - focused output + performance_start_trace: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + performance_stop_trace: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + + // Emulation tools + set_viewport: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + set_user_agent: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + set_geolocation: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + set_network_conditions: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, + set_cpu_throttling: { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, + }, +}; + +/** + * General default configuration for tools without specific settings. + */ +const GENERAL_DEFAULT: RequiredExpectationBase = { + includeSnapshot: false, + includeConsole: false, + includeNetwork: false, + includeTabs: false, +}; + +/** + * Get default expectation configuration for a specific tool. + */ +export function getDefaultExpectation( + toolName: string, +): RequiredExpectationBase { + return TOOL_DEFAULTS[toolName] ?? GENERAL_DEFAULT; +} + +/** + * Merge user-provided expectation with tool-specific defaults. + */ +export function mergeExpectations( + toolName: string, + userExpectation?: ExpectationOptions, +): NonNullable { + const defaults = getDefaultExpectation(toolName); + if (!userExpectation) { + return defaults; + } + return { + includeSnapshot: userExpectation.includeSnapshot ?? defaults.includeSnapshot, + includeConsole: userExpectation.includeConsole ?? defaults.includeConsole, + includeNetwork: userExpectation.includeNetwork ?? defaults.includeNetwork, + includeTabs: userExpectation.includeTabs ?? defaults.includeTabs, + snapshotOptions: userExpectation.snapshotOptions, + imageOptions: userExpectation.imageOptions, + }; +} diff --git a/src/formatters/SnapshotFormatter.ts b/src/formatters/SnapshotFormatter.ts index 2ec80e751..5310c0899 100644 --- a/src/formatters/SnapshotFormatter.ts +++ b/src/formatters/SnapshotFormatter.ts @@ -6,11 +6,21 @@ import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js'; +export interface SnapshotFormatterOptions { + /** + * Maximum length of the formatted snapshot string. + * If exceeded, the output will be truncated with a notice. + */ + maxLength?: number; +} + export class SnapshotFormatter { #snapshot: TextSnapshot; + #options: SnapshotFormatterOptions; - constructor(snapshot: TextSnapshot) { + constructor(snapshot: TextSnapshot, options?: SnapshotFormatterOptions) { this.#snapshot = snapshot; + this.#options = options ?? {}; } toString(): string { @@ -28,7 +38,17 @@ Get a verbose snapshot to include all elements if you are interested in the sele } chunks.push(this.#formatNode(root, 0)); - return chunks.join(''); + let result = chunks.join(''); + + // Apply maxLength truncation if specified + if (this.#options.maxLength && result.length > this.#options.maxLength) { + const truncateNotice = '\n\n... [truncated due to maxLength limit]'; + result = + result.slice(0, this.#options.maxLength - truncateNotice.length) + + truncateNotice; + } + + return result; } toJSON(): object { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 1cfa9751f..71ce58b81 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -51,6 +51,11 @@ export interface ImageContentData { export interface SnapshotParams { verbose?: boolean; filePath?: string; + /** + * Maximum length of the snapshot string. + * If exceeded, output will be truncated with a notice. + */ + maxLength?: number; } export interface DevToolsData { diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 143d04093..a5ad22732 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -32,11 +32,18 @@ in the DevTools Elements panel (if any).`, .describe( 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.', ), + maxLength: zod + .number() + .optional() + .describe( + 'Maximum characters for snapshot output. If exceeded, output is truncated with a notice. Useful for token efficiency.', + ), }, handler: async (request, response) => { response.includeSnapshot({ verbose: request.params.verbose ?? false, filePath: request.params.filePath, + maxLength: request.params.maxLength, }); }, }); From 49503129f4b6007f3ea6c8daf17f20786910ebf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Br=C3=A4nstr=C3=B6m?= Date: Sun, 25 Jan 2026 23:53:19 +0100 Subject: [PATCH 2/5] docs: add token optimization section to README Document the fork's token optimization features: - Snapshot truncation via maxLength parameter - Future enhancement infrastructure (expectation schema) Co-Authored-By: Claude Opus 4.5 --- README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 779b183e8..77661ed99 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ Chrome DevTools for reliable automation, in-depth debugging, and performance ana - **Reliable automation**. Uses [puppeteer](https://github.com/puppeteer/puppeteer) to automate actions in Chrome and automatically wait for action results. +- **Token-efficient responses** (fork enhancement): Minimize token usage with + snapshot truncation via `maxLength` parameter. Inspired by + [fast-playwright-mcp](https://github.com/nicobailon/fast-playwright-mcp). ## Disclaimers @@ -319,9 +322,33 @@ Check the performance of https://developers.chrome.com Your MCP client should open the browser and record a performance trace. -> [!NOTE] +> [!NOTE] > The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser. +## Token Optimization (Fork Enhancement) + +This fork includes token optimization features inspired by [fast-playwright-mcp](https://github.com/nicobailon/fast-playwright-mcp). + +### Snapshot Truncation + +Use the `maxLength` parameter with `take_snapshot` to limit output size: + +``` +Take a snapshot with maxLength of 5000 characters +``` + +The snapshot will be truncated with a notice if it exceeds the limit. This is useful for large pages where you only need a summary. + +### Future Enhancements + +The infrastructure for more token optimization features is in place: + +- **expectation schema**: Control which content to include in responses (snapshot, console, network, tabs) +- **snapshotOptions**: Limit snapshot scope by CSS selector or max length +- **imageOptions**: Control screenshot format, quality, and dimensions + +See `src/expectation.ts` for the full schema and tool defaults. + ## Tools If you run into any issues, checkout our [troubleshooting guide](./docs/troubleshooting.md). From 7db3bf106513206e4787127cb1983246d51b55a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Br=C3=A4nstr=C3=B6m?= Date: Mon, 26 Jan 2026 00:00:16 +0100 Subject: [PATCH 3/5] feat: add CSS selector filtering and image resizing for token optimization Phase 2 of token optimization implementation: Snapshot CSS selector filtering: - Added `selector` parameter to take_snapshot tool - Filters snapshot to only include subtree matching CSS selector - Uses CDP DOM.describeNode to map elements to accessibility nodes - Falls back to full snapshot with warning if selector doesn't match Screenshot image resizing: - Added `maxWidth` and `maxHeight` parameters to take_screenshot - Images resized maintaining aspect ratio using sharp library - Logs compression ratio when resizing occurs - Added sharp as production dependency Code changes: - src/utils/image-processor.ts: New utility for image processing - src/McpResponse.ts: Added #filterSnapshotBySelector private method - src/tools/screenshot.ts: Integrated image resizing - src/tools/snapshot.ts: Added selector parameter - src/tools/ToolDefinition.ts: Extended SnapshotParams with selector Documentation: - Updated README with full token optimization documentation - Added parameter reference table for all optimization options Co-Authored-By: Claude Opus 4.5 --- README.md | 41 ++- package-lock.json | 531 ++++++++++++++++++++++++++++++++++- package.json | 3 + src/McpResponse.ts | 92 +++++- src/tools/ToolDefinition.ts | 5 + src/tools/screenshot.ts | 47 +++- src/tools/snapshot.ts | 7 + src/utils/image-processor.ts | 178 ++++++++++++ 8 files changed, 879 insertions(+), 25 deletions(-) create mode 100644 src/utils/image-processor.ts diff --git a/README.md b/README.md index 77661ed99..71daa3bb4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Chrome DevTools for reliable automation, in-depth debugging, and performance ana [puppeteer](https://github.com/puppeteer/puppeteer) to automate actions in Chrome and automatically wait for action results. - **Token-efficient responses** (fork enhancement): Minimize token usage with - snapshot truncation via `maxLength` parameter. Inspired by + snapshot truncation (`maxLength`), CSS selector filtering (`selector`), and + image resizing (`maxWidth`/`maxHeight`). Inspired by [fast-playwright-mcp](https://github.com/nicobailon/fast-playwright-mcp). ## Disclaimers @@ -329,25 +330,45 @@ Your MCP client should open the browser and record a performance trace. This fork includes token optimization features inspired by [fast-playwright-mcp](https://github.com/nicobailon/fast-playwright-mcp). -### Snapshot Truncation +### Snapshot Optimization -Use the `maxLength` parameter with `take_snapshot` to limit output size: +Use the following parameters with `take_snapshot` to reduce token usage: +**Truncation** - Limit output to a maximum number of characters: ``` Take a snapshot with maxLength of 5000 characters ``` -The snapshot will be truncated with a notice if it exceeds the limit. This is useful for large pages where you only need a summary. +**CSS Selector Filtering** - Focus on a specific part of the page: +``` +Take a snapshot with selector "#main-content" +``` + +The `selector` parameter limits the snapshot to only the subtree rooted at the matching element. This dramatically reduces output size when you only need to inspect a specific component. + +### Screenshot Optimization + +Use `maxWidth` and `maxHeight` with `take_screenshot` to resize images: + +``` +Take a screenshot with maxWidth 800 and maxHeight 600 +``` -### Future Enhancements +Images are resized maintaining aspect ratio (using sharp library). This reduces the base64 payload size significantly, saving tokens when including screenshots in context. -The infrastructure for more token optimization features is in place: +### Additional Parameters -- **expectation schema**: Control which content to include in responses (snapshot, console, network, tabs) -- **snapshotOptions**: Limit snapshot scope by CSS selector or max length -- **imageOptions**: Control screenshot format, quality, and dimensions +| Tool | Parameter | Description | +|------|-----------|-------------| +| `take_snapshot` | `maxLength` | Maximum characters (truncates with notice) | +| `take_snapshot` | `selector` | CSS selector to limit scope | +| `take_snapshot` | `verbose` | Include all a11y tree info (default: false) | +| `take_screenshot` | `maxWidth` | Maximum width in pixels | +| `take_screenshot` | `maxHeight` | Maximum height in pixels | +| `take_screenshot` | `quality` | JPEG/WebP quality 0-100 | +| `take_screenshot` | `format` | png, jpeg, or webp | -See `src/expectation.ts` for the full schema and tool defaults. +See `src/expectation.ts` for the full expectation schema and tool defaults. ## Tools diff --git a/package-lock.json b/package-lock.json index c064f72af..db26dc9c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "chrome-devtools-mcp", "version": "0.13.0", "license": "Apache-2.0", + "dependencies": { + "sharp": "^0.34.5" + }, "bin": { "chrome-devtools-mcp": "build/src/index.js" }, @@ -85,10 +88,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "license": "MIT", "optional": true, "dependencies": { @@ -409,6 +411,471 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2880,6 +3347,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1551306", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", @@ -6386,7 +6862,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6490,6 +6965,50 @@ "dev": true, "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7108,7 +7627,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, + "devOptional": true, "license": "0BSD" }, "node_modules/type-check": { diff --git a/package.json b/package.json index e231eaab7..54879c5a4 100644 --- a/package.json +++ b/package.json @@ -73,5 +73,8 @@ }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "dependencies": { + "sharp": "^0.34.5" } } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 9dcf967e4..90c5ef184 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -8,7 +8,7 @@ import {ConsoleFormatter} from './formatters/ConsoleFormatter.js'; import {IssueFormatter} from './formatters/IssueFormatter.js'; import {NetworkFormatter} from './formatters/NetworkFormatter.js'; import {SnapshotFormatter} from './formatters/SnapshotFormatter.js'; -import type {McpContext} from './McpContext.js'; +import type {McpContext, TextSnapshot, TextSnapshotNode} from './McpContext.js'; import {DevTools} from './third_party/index.js'; import type { ConsoleMessage, @@ -232,7 +232,25 @@ export class McpResponse implements Response { this.#snapshotParams.verbose, this.#devToolsData, ); - const textSnapshot = context.getTextSnapshot(); + let textSnapshot = context.getTextSnapshot(); + + // Apply selector filtering if specified + if (textSnapshot && this.#snapshotParams.selector) { + const filteredSnapshot = await this.#filterSnapshotBySelector( + context, + textSnapshot, + this.#snapshotParams.selector, + ); + if (filteredSnapshot) { + textSnapshot = filteredSnapshot; + } else { + // Selector didn't match any element in the snapshot + this.#textResponseLines.push( + `Warning: No element found matching selector "${this.#snapshotParams.selector}". Returning full snapshot.`, + ); + } + } + if (textSnapshot) { const formatter = new SnapshotFormatter(textSnapshot, { maxLength: this.#snapshotParams.maxLength, @@ -633,4 +651,74 @@ Call ${handleDialog.name} to handle it before continuing.`); resetResponseLineForTesting() { this.#textResponseLines = []; } + + /** + * Filters a snapshot to only include the subtree rooted at the element + * matching the given CSS selector. + */ + async #filterSnapshotBySelector( + context: McpContext, + textSnapshot: TextSnapshot, + selector: string, + ): Promise { + const page = context.getSelectedPage(); + + // Find the element matching the selector + const element = await page.$(selector); + if (!element) { + return null; + } + + // Get the backendNodeId via CDP + // @ts-expect-error - accessing internal _client() method + const client = element._client(); + // @ts-expect-error - accessing internal _remoteObject property + const remoteObjectId = element._remoteObject.objectId; + + const {node} = await client.send('DOM.describeNode', { + objectId: remoteObjectId, + }); + const backendNodeId = node.backendNodeId as number; + + // Find the node in our snapshot tree by backendNodeId + const findNodeByBackendId = ( + node: TextSnapshotNode, + ): TextSnapshotNode | null => { + if (node.backendNodeId === backendNodeId) { + return node; + } + for (const child of node.children) { + const found = findNodeByBackendId(child); + if (found) { + return found; + } + } + return null; + }; + + const subtreeRoot = findNodeByBackendId(textSnapshot.root); + if (!subtreeRoot) { + return null; + } + + // Create a new idToNode map for just the subtree + const newIdToNode = new Map(); + const buildIdMap = (node: TextSnapshotNode) => { + newIdToNode.set(node.id, node); + for (const child of node.children) { + buildIdMap(child); + } + }; + buildIdMap(subtreeRoot); + + // Return a filtered snapshot + return { + root: subtreeRoot, + idToNode: newIdToNode, + snapshotId: textSnapshot.snapshotId, + selectedElementUid: textSnapshot.selectedElementUid, + hasSelectedElement: textSnapshot.hasSelectedElement, + verbose: textSnapshot.verbose, + }; + } } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 71ce58b81..8eb2cf04f 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -56,6 +56,11 @@ export interface SnapshotParams { * If exceeded, output will be truncated with a notice. */ maxLength?: number; + /** + * CSS selector to limit snapshot scope. + * Only the subtree rooted at the matching element will be included. + */ + selector?: string; } export interface DevToolsData { diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 4312c02aa..0fcc82db7 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -6,6 +6,7 @@ import {zod} from '../third_party/index.js'; import type {ElementHandle, Page} from '../third_party/index.js'; +import {processImage} from '../utils/image-processor.js'; import {ToolCategory} from './categories.js'; import {defineTool} from './ToolDefinition.js'; @@ -31,6 +32,18 @@ export const screenshot = defineTool({ .describe( 'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.', ), + maxWidth: zod + .number() + .optional() + .describe( + 'Maximum width in pixels. Image will be resized (maintaining aspect ratio) if larger. Useful for token efficiency.', + ), + maxHeight: zod + .number() + .optional() + .describe( + 'Maximum height in pixels. Image will be resized (maintaining aspect ratio) if larger. Useful for token efficiency.', + ), uid: zod .string() .optional() @@ -65,13 +78,33 @@ export const screenshot = defineTool({ const format = request.params.format; const quality = format === 'png' ? undefined : request.params.quality; - const screenshot = await pageOrHandle.screenshot({ + let screenshotData = await pageOrHandle.screenshot({ type: format, fullPage: request.params.fullPage, quality, optimizeForSpeed: true, // Bonus: optimize encoding for speed }); + let mimeType = `image/${format}`; + + // Apply image processing if resize options are specified + if (request.params.maxWidth || request.params.maxHeight) { + const processed = await processImage(screenshotData, mimeType, { + maxWidth: request.params.maxWidth, + maxHeight: request.params.maxHeight, + format: format, + quality: quality, + }); + screenshotData = processed.data; + mimeType = processed.mimeType; + + if (processed.compressionRatio < 1) { + response.appendResponseLine( + `Resized from ${processed.originalSize.width}x${processed.originalSize.height} to ${processed.processedSize.width}x${processed.processedSize.height} (${Math.round(processed.compressionRatio * 100)}% of original size).`, + ); + } + } + if (request.params.uid) { response.appendResponseLine( `Took a screenshot of node with uid "${request.params.uid}".`, @@ -87,18 +120,18 @@ export const screenshot = defineTool({ } if (request.params.filePath) { - const file = await context.saveFile(screenshot, request.params.filePath); + const file = await context.saveFile(screenshotData, request.params.filePath); response.appendResponseLine(`Saved screenshot to ${file.filename}.`); - } else if (screenshot.length >= 2_000_000) { + } else if (screenshotData.length >= 2_000_000) { const {filename} = await context.saveTemporaryFile( - screenshot, - `image/${request.params.format}`, + screenshotData, + mimeType as 'image/png' | 'image/jpeg' | 'image/webp', ); response.appendResponseLine(`Saved screenshot to ${filename}.`); } else { response.attachImage({ - mimeType: `image/${request.params.format}`, - data: Buffer.from(screenshot).toString('base64'), + mimeType, + data: Buffer.from(screenshotData).toString('base64'), }); } }, diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index a5ad22732..5c1faddab 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -38,12 +38,19 @@ in the DevTools Elements panel (if any).`, .describe( 'Maximum characters for snapshot output. If exceeded, output is truncated with a notice. Useful for token efficiency.', ), + selector: zod + .string() + .optional() + .describe( + 'CSS selector to limit snapshot scope. Only the subtree rooted at the matching element will be included. Useful for focusing on specific page sections.', + ), }, handler: async (request, response) => { response.includeSnapshot({ verbose: request.params.verbose ?? false, filePath: request.params.filePath, maxLength: request.params.maxLength, + selector: request.params.selector, }); }, }); diff --git a/src/utils/image-processor.ts b/src/utils/image-processor.ts new file mode 100644 index 000000000..7bab65993 --- /dev/null +++ b/src/utils/image-processor.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Image processing utilities for token optimization. + * Inspired by fast-playwright-mcp. + */ + +import sharp from 'sharp'; + +import type {ImageOptions} from '../expectation.js'; + +export interface ImageSize { + width: number; + height: number; +} + +export interface ProcessedImage { + data: Buffer; + mimeType: string; + originalSize: ImageSize; + processedSize: ImageSize; + compressionRatio: number; +} + +/** + * Process an image with optional resizing and format conversion. + * Returns the processed image buffer and metadata. + */ +export async function processImage( + data: Uint8Array, + mimeType: string, + options?: ImageOptions, +): Promise { + const inputBuffer = Buffer.from(data); + + // Return original if no options provided + if (!options || Object.keys(options).length === 0) { + return processImageWithoutOptions(inputBuffer, mimeType); + } + + try { + // Get original metadata + const originalMetadata = await sharp(inputBuffer).metadata(); + const originalSize: ImageSize = { + width: originalMetadata.width || 0, + height: originalMetadata.height || 0, + }; + + let image = sharp(inputBuffer); + + // Apply resizing if specified + if (options.maxWidth || options.maxHeight) { + image = image.resize(options.maxWidth, options.maxHeight, { + fit: 'inside', + withoutEnlargement: true, + }); + } + + // Apply format and quality options + let outputMimeType = mimeType; + if (options.format) { + const result = applyFormatConversion(image, options.format, options.quality); + image = result.image; + outputMimeType = result.mimeType; + } else if (options.quality) { + image = applyQualityToExistingFormat(image, mimeType, options.quality); + } + + // Process the image + const processedBuffer = await image.toBuffer(); + const processedMetadata = await sharp(processedBuffer).metadata(); + + const processedSize: ImageSize = { + width: processedMetadata.width || originalSize.width, + height: processedMetadata.height || originalSize.height, + }; + + // Calculate compression ratio + const compressionRatio = processedBuffer.length / inputBuffer.length; + + return { + data: processedBuffer, + mimeType: outputMimeType, + originalSize, + processedSize, + compressionRatio, + }; + } catch (error) { + throw new Error( + `Image processing failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Process image without options (return original with metadata). + */ +async function processImageWithoutOptions( + data: Buffer, + mimeType: string, +): Promise { + try { + const metadata = await sharp(data).metadata(); + return { + data, + mimeType, + originalSize: { + width: metadata.width || 0, + height: metadata.height || 0, + }, + processedSize: { + width: metadata.width || 0, + height: metadata.height || 0, + }, + compressionRatio: 1.0, + }; + } catch { + // If Sharp fails, return basic structure + return { + data, + mimeType, + originalSize: {width: 0, height: 0}, + processedSize: {width: 0, height: 0}, + compressionRatio: 1.0, + }; + } +} + +/** + * Apply format conversion to image. + */ +function applyFormatConversion( + image: sharp.Sharp, + format: string, + quality?: number, +): {image: sharp.Sharp; mimeType: string} { + const outputMimeType = `image/${format}`; + let processedImage = image; + + switch (format) { + case 'jpeg': + processedImage = image.jpeg({quality: quality || 80}); + break; + case 'png': + processedImage = image.png({quality: quality || 100}); + break; + case 'webp': + processedImage = image.webp({quality: quality || 80}); + break; + default: + // For unsupported formats, keep original + break; + } + + return {image: processedImage, mimeType: outputMimeType}; +} + +/** + * Apply quality settings to existing format. + */ +function applyQualityToExistingFormat( + image: sharp.Sharp, + mimeType: string, + quality: number, +): sharp.Sharp { + if (mimeType.includes('jpeg')) { + return image.jpeg({quality}); + } + if (mimeType.includes('png')) { + return image.png({quality}); + } + if (mimeType.includes('webp')) { + return image.webp({quality}); + } + return image; +} From 03652e31786e461ca474b6aa53ba08f787937f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Br=C3=A4nstr=C3=B6m?= Date: Mon, 26 Jan 2026 00:30:42 +0100 Subject: [PATCH 4/5] test: add tests for token optimization features - Add maxLength truncation tests to snapshotFormatter.test.ts - Add new image-processor.test.ts with tests for resize and format conversion - Use sharp to generate valid test images (more reliable than manual PNG bytes) Co-Authored-By: Claude Opus 4.5 --- tests/formatters/snapshotFormatter.test.ts | 70 ++++++++++++ tests/utils/image-processor.test.ts | 127 +++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 tests/utils/image-processor.test.ts diff --git a/tests/formatters/snapshotFormatter.test.ts b/tests/formatters/snapshotFormatter.test.ts index 281cc17ab..2cc83e95d 100644 --- a/tests/formatters/snapshotFormatter.test.ts +++ b/tests/formatters/snapshotFormatter.test.ts @@ -292,4 +292,74 @@ describe('snapshotFormatter', () => { ], }); }); + + describe('maxLength truncation', () => { + it('truncates output when exceeding maxLength', () => { + const node: TextSnapshotNode = { + id: '1_1', + role: 'root', + name: 'root', + children: [ + { + id: '1_2', + role: 'button', + name: 'This is a very long button name that will cause the output to exceed the maxLength limit', + children: [], + elementHandle: async () => null, + }, + { + id: '1_3', + role: 'textbox', + name: 'Another element with a long name', + children: [], + elementHandle: async () => null, + }, + ], + elementHandle: async () => null, + }; + + const formatter = new SnapshotFormatter( + {root: node} as TextSnapshot, + {maxLength: 50}, + ); + const formatted = formatter.toString(); + + assert.ok(formatted.length <= 50); + assert.ok(formatted.includes('[truncated')); + }); + + it('does not truncate when under maxLength', () => { + const node: TextSnapshotNode = { + id: '1_1', + role: 'button', + name: 'btn', + children: [], + elementHandle: async () => null, + }; + + const formatter = new SnapshotFormatter( + {root: node} as TextSnapshot, + {maxLength: 1000}, + ); + const formatted = formatter.toString(); + + assert.ok(!formatted.includes('[truncated')); + }); + + it('works without maxLength option', () => { + const node: TextSnapshotNode = { + id: '1_1', + role: 'button', + name: 'button', + children: [], + elementHandle: async () => null, + }; + + const formatter = new SnapshotFormatter({root: node} as TextSnapshot); + const formatted = formatter.toString(); + + assert.ok(formatted.includes('button')); + assert.ok(!formatted.includes('[truncated')); + }); + }); }); diff --git a/tests/utils/image-processor.test.ts b/tests/utils/image-processor.test.ts new file mode 100644 index 000000000..cf3bfb7f4 --- /dev/null +++ b/tests/utils/image-processor.test.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it, before} from 'node:test'; + +import sharp from 'sharp'; + +import {processImage} from '../../src/utils/image-processor.js'; + +// Create a valid test PNG using sharp (100x100 red square) +async function createTestPng(): Promise { + const buffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 3, + background: {r: 255, g: 0, b: 0}, + }, + }) + .png() + .toBuffer(); + return new Uint8Array(buffer); +} + +// Cache the test image to avoid regenerating for each test +let testPngCache: Uint8Array | null = null; +async function getTestPng(): Promise { + if (!testPngCache) { + testPngCache = await createTestPng(); + } + return testPngCache; +} + +describe('image-processor', () => { + describe('processImage', () => { + it('returns original image when no options provided', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png'); + + assert.strictEqual(result.mimeType, 'image/png'); + assert.strictEqual(result.compressionRatio, 1.0); + assert.strictEqual(result.originalSize.width, result.processedSize.width); + assert.strictEqual(result.originalSize.height, result.processedSize.height); + }); + + it('returns original image when empty options provided', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png', {}); + + assert.strictEqual(result.mimeType, 'image/png'); + assert.strictEqual(result.compressionRatio, 1.0); + }); + + it('respects maxWidth option', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png', {maxWidth: 50}); + + // 100x100 image resized to max 50 width + assert.strictEqual(result.processedSize.width, 50); + assert.strictEqual(result.processedSize.height, 50); + }); + + it('respects maxHeight option', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png', {maxHeight: 50}); + + // 100x100 image resized to max 50 height + assert.strictEqual(result.processedSize.width, 50); + assert.strictEqual(result.processedSize.height, 50); + }); + + it('converts format when specified', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png', {format: 'jpeg'}); + + assert.strictEqual(result.mimeType, 'image/jpeg'); + }); + + it('applies quality setting for jpeg', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png', { + format: 'jpeg', + quality: 50, + }); + + assert.strictEqual(result.mimeType, 'image/jpeg'); + assert.ok(result.data.length > 0); + }); + + it('applies quality setting for webp', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png', { + format: 'webp', + quality: 80, + }); + + assert.strictEqual(result.mimeType, 'image/webp'); + assert.ok(result.data.length > 0); + }); + + it('returns metadata about processing', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png', {maxWidth: 50}); + + assert.ok('originalSize' in result); + assert.ok('processedSize' in result); + assert.ok('compressionRatio' in result); + assert.strictEqual(result.originalSize.width, 100); + assert.strictEqual(result.originalSize.height, 100); + assert.strictEqual(result.processedSize.width, 50); + assert.strictEqual(result.processedSize.height, 50); + }); + + it('calculates compression ratio correctly', async () => { + const testPng = await getTestPng(); + const result = await processImage(testPng, 'image/png', {maxWidth: 50}); + + // Resized image should be smaller + assert.ok(result.compressionRatio < 1.0); + assert.ok(result.data.length < testPng.length); + }); + }); +}); From bebd8b1ee4d86d7a5223d2fef105c92264f993ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Br=C3=A4nstr=C3=B6m?= Date: Mon, 26 Jan 2026 00:33:12 +0100 Subject: [PATCH 5/5] docs: regenerate tool reference with new parameters - maxWidth, maxHeight for take_screenshot - maxLength, selector for take_snapshot - Format fixes from prettier Co-Authored-By: Claude Opus 4.5 --- README.md | 20 ++++---- docs/tool-reference.md | 4 ++ src/expectation.ts | 59 ++++++++++++---------- src/tools/screenshot.ts | 5 +- src/utils/image-processor.ts | 6 ++- tests/formatters/snapshotFormatter.test.ts | 14 +++-- tests/utils/image-processor.test.ts | 7 ++- 7 files changed, 68 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 71daa3bb4..903eb5b7d 100644 --- a/README.md +++ b/README.md @@ -335,11 +335,13 @@ This fork includes token optimization features inspired by [fast-playwright-mcp] Use the following parameters with `take_snapshot` to reduce token usage: **Truncation** - Limit output to a maximum number of characters: + ``` Take a snapshot with maxLength of 5000 characters ``` **CSS Selector Filtering** - Focus on a specific part of the page: + ``` Take a snapshot with selector "#main-content" ``` @@ -358,15 +360,15 @@ Images are resized maintaining aspect ratio (using sharp library). This reduces ### Additional Parameters -| Tool | Parameter | Description | -|------|-----------|-------------| -| `take_snapshot` | `maxLength` | Maximum characters (truncates with notice) | -| `take_snapshot` | `selector` | CSS selector to limit scope | -| `take_snapshot` | `verbose` | Include all a11y tree info (default: false) | -| `take_screenshot` | `maxWidth` | Maximum width in pixels | -| `take_screenshot` | `maxHeight` | Maximum height in pixels | -| `take_screenshot` | `quality` | JPEG/WebP quality 0-100 | -| `take_screenshot` | `format` | png, jpeg, or webp | +| Tool | Parameter | Description | +| ----------------- | ----------- | ------------------------------------------- | +| `take_snapshot` | `maxLength` | Maximum characters (truncates with notice) | +| `take_snapshot` | `selector` | CSS selector to limit scope | +| `take_snapshot` | `verbose` | Include all a11y tree info (default: false) | +| `take_screenshot` | `maxWidth` | Maximum width in pixels | +| `take_screenshot` | `maxHeight` | Maximum height in pixels | +| `take_screenshot` | `quality` | JPEG/WebP quality 0-100 | +| `take_screenshot` | `format` | png, jpeg, or webp | See `src/expectation.ts` for the full expectation schema and tool defaults. diff --git a/docs/tool-reference.md b/docs/tool-reference.md index a8605b837..b4d7c81d0 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -340,6 +340,8 @@ so returned values have to JSON-serializable. - **filePath** (string) _(optional)_: The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response. - **format** (enum: "png", "jpeg", "webp") _(optional)_: Type of format to save the screenshot as. Default is "png" - **fullPage** (boolean) _(optional)_: If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid. +- **maxHeight** (number) _(optional)_: Maximum height in pixels. Image will be resized (maintaining aspect ratio) if larger. Useful for token efficiency. +- **maxWidth** (number) _(optional)_: Maximum width in pixels. Image will be resized (maintaining aspect ratio) if larger. Useful for token efficiency. - **quality** (number) _(optional)_: Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format. - **uid** (string) _(optional)_: The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot. @@ -354,6 +356,8 @@ in the DevTools Elements panel (if any). **Parameters:** - **filePath** (string) _(optional)_: The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response. +- **maxLength** (number) _(optional)_: Maximum characters for snapshot output. If exceeded, output is truncated with a notice. Useful for token efficiency. +- **selector** (string) _(optional)_: CSS selector to limit snapshot scope. Only the subtree rooted at the matching element will be included. Useful for focusing on specific page sections. - **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false. --- diff --git a/src/expectation.ts b/src/expectation.ts index 4b59d44a5..c3e1bbf72 100644 --- a/src/expectation.ts +++ b/src/expectation.ts @@ -18,7 +18,9 @@ export const snapshotOptionsSchema = zod selector: zod .string() .optional() - .describe('CSS selector to limit snapshot scope (e.g., ".main-content", "form")'), + .describe( + 'CSS selector to limit snapshot scope (e.g., ".main-content", "form")', + ), maxLength: zod .number() .optional() @@ -61,30 +63,34 @@ export const imageOptionsSchema = zod * Schema for expectation configuration that controls response content. * All options default to false for maximum token efficiency. */ -export const expectationSchema = zod.object({ - includeSnapshot: zod - .boolean() - .optional() - .default(false) - .describe('Include accessibility tree snapshot (false saves ~40% tokens)'), - includeConsole: zod - .boolean() - .optional() - .default(false) - .describe('Include console messages'), - includeNetwork: zod - .boolean() - .optional() - .default(false) - .describe('Include network requests'), - includeTabs: zod - .boolean() - .optional() - .default(false) - .describe('Include tab/page information'), - snapshotOptions: snapshotOptionsSchema, - imageOptions: imageOptionsSchema, -}).optional(); +export const expectationSchema = zod + .object({ + includeSnapshot: zod + .boolean() + .optional() + .default(false) + .describe( + 'Include accessibility tree snapshot (false saves ~40% tokens)', + ), + includeConsole: zod + .boolean() + .optional() + .default(false) + .describe('Include console messages'), + includeNetwork: zod + .boolean() + .optional() + .default(false) + .describe('Include network requests'), + includeTabs: zod + .boolean() + .optional() + .default(false) + .describe('Include tab/page information'), + snapshotOptions: snapshotOptionsSchema, + imageOptions: imageOptionsSchema, + }) + .optional(); export type ExpectationOptions = zod.infer; export type SnapshotOptions = zod.infer; @@ -328,7 +334,8 @@ export function mergeExpectations( return defaults; } return { - includeSnapshot: userExpectation.includeSnapshot ?? defaults.includeSnapshot, + includeSnapshot: + userExpectation.includeSnapshot ?? defaults.includeSnapshot, includeConsole: userExpectation.includeConsole ?? defaults.includeConsole, includeNetwork: userExpectation.includeNetwork ?? defaults.includeNetwork, includeTabs: userExpectation.includeTabs ?? defaults.includeTabs, diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 0fcc82db7..f885a14ae 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -120,7 +120,10 @@ export const screenshot = defineTool({ } if (request.params.filePath) { - const file = await context.saveFile(screenshotData, request.params.filePath); + const file = await context.saveFile( + screenshotData, + request.params.filePath, + ); response.appendResponseLine(`Saved screenshot to ${file.filename}.`); } else if (screenshotData.length >= 2_000_000) { const {filename} = await context.saveTemporaryFile( diff --git a/src/utils/image-processor.ts b/src/utils/image-processor.ts index 7bab65993..03bc5f43b 100644 --- a/src/utils/image-processor.ts +++ b/src/utils/image-processor.ts @@ -61,7 +61,11 @@ export async function processImage( // Apply format and quality options let outputMimeType = mimeType; if (options.format) { - const result = applyFormatConversion(image, options.format, options.quality); + const result = applyFormatConversion( + image, + options.format, + options.quality, + ); image = result.image; outputMimeType = result.mimeType; } else if (options.quality) { diff --git a/tests/formatters/snapshotFormatter.test.ts b/tests/formatters/snapshotFormatter.test.ts index 2cc83e95d..0aef60738 100644 --- a/tests/formatters/snapshotFormatter.test.ts +++ b/tests/formatters/snapshotFormatter.test.ts @@ -318,10 +318,9 @@ describe('snapshotFormatter', () => { elementHandle: async () => null, }; - const formatter = new SnapshotFormatter( - {root: node} as TextSnapshot, - {maxLength: 50}, - ); + const formatter = new SnapshotFormatter({root: node} as TextSnapshot, { + maxLength: 50, + }); const formatted = formatter.toString(); assert.ok(formatted.length <= 50); @@ -337,10 +336,9 @@ describe('snapshotFormatter', () => { elementHandle: async () => null, }; - const formatter = new SnapshotFormatter( - {root: node} as TextSnapshot, - {maxLength: 1000}, - ); + const formatter = new SnapshotFormatter({root: node} as TextSnapshot, { + maxLength: 1000, + }); const formatted = formatter.toString(); assert.ok(!formatted.includes('[truncated')); diff --git a/tests/utils/image-processor.test.ts b/tests/utils/image-processor.test.ts index cf3bfb7f4..046f3ca46 100644 --- a/tests/utils/image-processor.test.ts +++ b/tests/utils/image-processor.test.ts @@ -5,7 +5,7 @@ */ import assert from 'node:assert'; -import {describe, it, before} from 'node:test'; +import {describe, it} from 'node:test'; import sharp from 'sharp'; @@ -44,7 +44,10 @@ describe('image-processor', () => { assert.strictEqual(result.mimeType, 'image/png'); assert.strictEqual(result.compressionRatio, 1.0); assert.strictEqual(result.originalSize.width, result.processedSize.width); - assert.strictEqual(result.originalSize.height, result.processedSize.height); + assert.strictEqual( + result.originalSize.height, + result.processedSize.height, + ); }); it('returns original image when empty options provided', async () => {