chromedevtools--chrome-devtools-mcp
55c8a541d4
## Summary Adds **opt-in** CLI flags so operators can cap the size of screenshots returned by `take_screenshot` before they are embedded in the MCP response. Refs #879. The flags address two related symptoms reported when MCP clients display screenshots inline: 1. **Per-image dimension limit**: hosted LLM APIs commonly reject images exceeding per-image dimension constraints (typical caps are in the 2000-8000 px range, sometimes scaling down further when many images are in the same request). This is the exact error reported in #879. 2. **Cumulative request size**: after many captures, the cumulative base64 payload eventually pushes a request over the per-call body size limit imposed by the LLM API. Both can be mitigated at the source by reducing format/quality and downscaling the capture. ## New flags (all opt-in) - `--screenshot-format <jpeg|png|webp>`: override the default format used by `take_screenshot` when the caller does not specify one - `--screenshot-quality <0-100>`: override the default JPEG/WebP quality. Ignored for PNG - `--screenshot-max-width <px>`: downscale screenshots wider than this before they are returned - `--screenshot-max-height <px>`: downscale screenshots taller than this. Combines with `--screenshot-max-width`; the smaller scale wins so both bounds are respected while preserving aspect ratio For the exact error in #879, the recipe is `--screenshot-max-width=8000 --screenshot-max-height=8000` (or a smaller value such as `2000` if many images may end up in the same request, depending on the operator's chosen API). ## Implementation - Resizing leverages Puppeteer's `clip.scale` (CDP `Page.captureScreenshot`), so **no new dependencies**. - Source dimensions per capture mode: - viewport: `page.viewport()` - full page: `document.documentElement.scrollWidth/scrollHeight` via `page.evaluate()` - element (`uid`): `elementHandle.boundingBox()` - For element and full-page captures with a downscale clip, the call routes through `page.screenshot({clip})` so the scale parameter applies. `captureBeyondViewport` is left to Puppeteer's default (`true` when a clip is set), preserving correct behavior for elements below the fold and full-page captures. - ~150 lines of source code, ~200 lines of new tests. ## Backwards compatibility **Fully opt-in**: when no flags are set, `take_screenshot` returns the exact same bytes as before. No behavioral change for existing users. ## Design alignment - Aligned with the **"Reference over Value"** principle in `docs/design-principles.md`: the existing 2 MB threshold still routes oversized screenshots to a temporary file. This change only reduces the size of the **inline base64 fallback path**, which the principles document calls out as an acceptable exception when MCP clients display images natively. - The MCP server **hardcodes no LLM-specific size limits**. Operators pick the values that match their client/model combination. This keeps the maintenance surface here minimal as model limits evolve, and is intended as a **complement to, not a replacement for**, fixes in the MCP client itself. ## Addressing concerns raised in #879 > "It's not feasible for us to maintain this. Limits will change when models change." (@natorion) The flags are pure parameters; nothing about the upstream LLM is encoded in the server. When a vendor raises (or lowers) a limit, no code change is needed here, only the operator's CLI args change. > "`filePath` / `page_resize` already work as a workaround." (@OrKoN) `filePath` is great when the call site knows it's about to take a huge screenshot, but as you noted earlier in the thread, an oversized image already in the request history keeps causing failures even on subsequent calls. `page_resize` works but mutates the page being debugged. The resize in this PR happens **between Puppeteer and the MCP response**, so the inspected page is untouched and the failure mode is prevented at the source. > "Should be fixed client side." Agreed, this PR is intended as a complement, not a substitute. A client-side fix (e.g. compaction evicts/downsamples old images) handles the cumulative case for *any* MCP. A server-side cap handles the per-call dimension limit for users who hit it before compaction can kick in. The two address overlapping but distinct failure modes. Happy to drop or rework any of this if the maintainers prefer a different shape, for example making the threshold automatic from a single `--max-image-bytes` knob, or rejecting the PR entirely in favor of waiting for a client-side fix. Just wanted to put a concrete option on the table. ## Tests Added 6 new tests: - `honors screenshotFormat default from CLI args` - `keeps "png" as default format when no CLI override is set` - `downscales viewport screenshot when screenshotMaxWidth is set` - `downscales using the smaller scale when both max-width and max-height are set` - `does not resize when source is smaller than the max bounds` - `downscales full page screenshot when screenshotMaxWidth is set` All 627 tests in the suite pass. `npm run typecheck` and `npm run check-format` are clean. ## Notes for reviewers - The dimensions compared against `--screenshot-max-width/height` are **CSS pixels** (`page.viewport()`), not raw bitmap pixels. With `deviceScaleFactor > 1` (HiDPI emulation) the actual bitmap may still be larger. Happy to clarify this in the option description if preferred. - For element captures with a downscale clip, the call routes through `page.screenshot({clip})` instead of `element.screenshot()`. Same-frame elements are correct (boundingBox returns main-frame coords). I have **not** exercised this path against cross-origin iframe elements; let me know if you'd like a fallback there. - The PR is currently in **Draft** state pending CLA verification and any feedback on the framing above. Refs #879 Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/879
454 行
14 KiB
TypeScript
454 行
14 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import {rm, stat, mkdir, chmod, writeFile} from 'node:fs/promises';
|
|
import {tmpdir} from 'node:os';
|
|
import {join} from 'node:path';
|
|
import {describe, it} from 'node:test';
|
|
|
|
import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
|
|
import {TextSnapshot} from '../../src/TextSnapshot.js';
|
|
import {screenshot} from '../../src/tools/screenshot.js';
|
|
import {screenshots} from '../snapshot.js';
|
|
import {html, withMcpContext} from '../utils.js';
|
|
|
|
const screenshotTool = screenshot({} as ParsedArguments);
|
|
|
|
/**
|
|
* Reads the pixel width from a PNG buffer's IHDR chunk (bytes 16..19).
|
|
*/
|
|
function pngWidth(data: Buffer): number {
|
|
return data.readUInt32BE(16);
|
|
}
|
|
|
|
/**
|
|
* Reads the pixel height from a PNG buffer's IHDR chunk (bytes 20..23).
|
|
*/
|
|
function pngHeight(data: Buffer): number {
|
|
return data.readUInt32BE(20);
|
|
}
|
|
|
|
describe('screenshot', () => {
|
|
describe('browser_take_screenshot', () => {
|
|
it('with default options', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const fixture = screenshots.basic;
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
await screenshotTool.handler(
|
|
{params: {format: 'png'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 1);
|
|
assert.equal(response.images[0].mimeType, 'image/png');
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
"Took a screenshot of the current page's viewport.",
|
|
);
|
|
});
|
|
});
|
|
it('ignores quality', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const fixture = screenshots.basic;
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
await screenshotTool.handler(
|
|
{
|
|
params: {format: 'png', quality: 0},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 1);
|
|
assert.equal(response.images[0].mimeType, 'image/png');
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
"Took a screenshot of the current page's viewport.",
|
|
);
|
|
});
|
|
});
|
|
it('with jpeg', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await screenshotTool.handler(
|
|
{params: {format: 'jpeg'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 1);
|
|
assert.equal(response.images[0].mimeType, 'image/jpeg');
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
"Took a screenshot of the current page's viewport.",
|
|
);
|
|
});
|
|
});
|
|
it('with webp', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await screenshotTool.handler(
|
|
{params: {format: 'webp'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 1);
|
|
assert.equal(response.images[0].mimeType, 'image/webp');
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
"Took a screenshot of the current page's viewport.",
|
|
);
|
|
});
|
|
});
|
|
it('with full page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const fixture = screenshots.viewportOverflow;
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
await screenshotTool.handler(
|
|
{
|
|
params: {format: 'png', fullPage: true},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 1);
|
|
assert.equal(response.images[0].mimeType, 'image/png');
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
'Took a screenshot of the full current page.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('with full page resulting in a large screenshot', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
|
|
await page.setContent(
|
|
html`${`<div style="color:blue;">test</div>`.repeat(6500)}
|
|
<div
|
|
id="red"
|
|
style="color:blue;"
|
|
>test</div
|
|
> `,
|
|
);
|
|
await page.evaluate(() => {
|
|
const el = document.querySelector('#red');
|
|
return el?.scrollIntoViewIfNeeded();
|
|
});
|
|
|
|
await screenshotTool.handler(
|
|
{
|
|
params: {format: 'png', fullPage: true},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 0);
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
'Took a screenshot of the full current page.',
|
|
);
|
|
assert.ok(
|
|
response.responseLines.at(1)?.match(/Saved screenshot to.*\.png/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('with element uid', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const fixture = screenshots.button;
|
|
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await screenshotTool.handler(
|
|
{
|
|
params: {
|
|
format: 'png',
|
|
uid: '1_1',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 1);
|
|
assert.equal(response.images[0].mimeType, 'image/png');
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
'Took a screenshot of node with uid "1_1".',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('with filePath', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(tmpdir(), 'test-screenshot.png');
|
|
try {
|
|
const fixture = screenshots.basic;
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
await screenshotTool.handler(
|
|
{
|
|
params: {format: 'png', filePath},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 0);
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
"Took a screenshot of the current page's viewport.",
|
|
);
|
|
assert.equal(
|
|
response.responseLines.at(1),
|
|
`Saved screenshot to ${filePath}.`,
|
|
);
|
|
|
|
const stats = await stat(filePath);
|
|
assert.ok(stats.isFile());
|
|
assert.ok(stats.size > 0);
|
|
} finally {
|
|
await rm(filePath, {force: true});
|
|
}
|
|
});
|
|
});
|
|
|
|
it('with unwritable filePath', async () => {
|
|
if (process.platform === 'win32') {
|
|
const filePath = join(
|
|
tmpdir(),
|
|
'readonly-file-for-screenshot-test.png',
|
|
);
|
|
// Create the file and make it read-only.
|
|
await writeFile(filePath, '');
|
|
await chmod(filePath, 0o400);
|
|
|
|
try {
|
|
await withMcpContext(async (response, context) => {
|
|
const fixture = screenshots.basic;
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
await assert.rejects(
|
|
screenshotTool.handler(
|
|
{
|
|
params: {format: 'png', filePath},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
),
|
|
);
|
|
});
|
|
} finally {
|
|
// Make the file writable again so it can be deleted.
|
|
await chmod(filePath, 0o600);
|
|
await rm(filePath, {force: true});
|
|
}
|
|
} else {
|
|
const dir = join(tmpdir(), 'readonly-dir-for-screenshot-test');
|
|
await mkdir(dir, {recursive: true});
|
|
await chmod(dir, 0o500);
|
|
const filePath = join(dir, 'test-screenshot.png');
|
|
|
|
try {
|
|
await withMcpContext(async (response, context) => {
|
|
const fixture = screenshots.basic;
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
await assert.rejects(
|
|
screenshotTool.handler(
|
|
{
|
|
params: {format: 'png', filePath},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
),
|
|
);
|
|
});
|
|
} finally {
|
|
await chmod(dir, 0o700);
|
|
await rm(dir, {recursive: true, force: true});
|
|
}
|
|
}
|
|
});
|
|
|
|
it('honors screenshotFormat default from CLI args', async () => {
|
|
const tool = screenshot({
|
|
screenshotFormat: 'jpeg',
|
|
} as ParsedArguments);
|
|
await withMcpContext(async (response, context) => {
|
|
const fixture = screenshots.basic;
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
// No explicit format passed: zod should apply the CLI-driven default.
|
|
await tool.handler(
|
|
{
|
|
params: {format: tool.schema.format.parse(undefined)},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 1);
|
|
assert.equal(response.images[0].mimeType, 'image/jpeg');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('downscales viewport screenshot when screenshotMaxWidth is set', async () => {
|
|
const tool = screenshot({
|
|
screenshotMaxWidth: 100,
|
|
} as ParsedArguments);
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setViewport({width: 800, height: 600});
|
|
await page.setContent(
|
|
html`<div style="width:100vw;height:100vh;background:red"></div>`,
|
|
);
|
|
|
|
await tool.handler(
|
|
{params: {format: 'png'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(response.images.length, 1);
|
|
const buf = Buffer.from(response.images[0].data, 'base64');
|
|
assert.equal(pngWidth(buf), 100);
|
|
// Aspect ratio preserved: 800x600 -> 100x75.
|
|
assert.equal(pngHeight(buf), 75);
|
|
});
|
|
});
|
|
|
|
it('downscales using the smaller scale when both max-width and max-height are set', async () => {
|
|
const tool = screenshot({
|
|
screenshotMaxWidth: 400,
|
|
screenshotMaxHeight: 60,
|
|
} as ParsedArguments);
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setViewport({width: 800, height: 600});
|
|
await page.setContent(
|
|
html`<div style="width:100vw;height:100vh"></div>`,
|
|
);
|
|
|
|
await tool.handler(
|
|
{params: {format: 'png'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const buf = Buffer.from(response.images[0].data, 'base64');
|
|
// height bound dictates: 60/600 = 0.1 -> 80x60.
|
|
assert.equal(pngHeight(buf), 60);
|
|
assert.equal(pngWidth(buf), 80);
|
|
});
|
|
});
|
|
|
|
it('does not resize when source is smaller than the max bounds', async () => {
|
|
const tool = screenshot({
|
|
screenshotMaxWidth: 4000,
|
|
screenshotMaxHeight: 4000,
|
|
} as ParsedArguments);
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setViewport({width: 800, height: 600});
|
|
await page.setContent(html`<div></div>`);
|
|
|
|
await tool.handler(
|
|
{params: {format: 'png'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const buf = Buffer.from(response.images[0].data, 'base64');
|
|
assert.equal(pngWidth(buf), 800);
|
|
assert.equal(pngHeight(buf), 600);
|
|
});
|
|
});
|
|
|
|
it('downscales full page screenshot when screenshotMaxWidth is set', async () => {
|
|
const tool = screenshot({
|
|
screenshotMaxWidth: 200,
|
|
} as ParsedArguments);
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setViewport({width: 800, height: 600});
|
|
await page.setContent(
|
|
html`<style>
|
|
body {
|
|
margin: 0;
|
|
}</style
|
|
><div style="width:1000px;height:1500px;background:red"></div>`,
|
|
);
|
|
|
|
await tool.handler(
|
|
{
|
|
params: {format: 'png', fullPage: true},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const buf = Buffer.from(response.images[0].data, 'base64');
|
|
// Source is at least 1000x1500; scale = 200/1000 = 0.2 -> ~200x300.
|
|
// Allow ±2px to absorb sub-pixel rasterization rounding by Chrome.
|
|
assert.equal(pngWidth(buf), 200);
|
|
assert.ok(
|
|
Math.abs(pngHeight(buf) - 300) <= 2,
|
|
`expected height near 300, got ${pngHeight(buf)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('with malformed filePath', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
// Use a platform-specific invalid character.
|
|
// On Windows, characters like '<', '>', ':', '"', '/', '\', '|', '?', '*' are invalid.
|
|
// On POSIX, the null byte is invalid.
|
|
const invalidChar = process.platform === 'win32' ? '>' : '\0';
|
|
const filePath = `malformed${invalidChar}path.png`;
|
|
const fixture = screenshots.basic;
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(fixture.html);
|
|
await assert.rejects(
|
|
screenshotTool.handler(
|
|
{
|
|
params: {format: 'png', filePath},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|