Skip to content

Commit f09e1ea

Browse files
committed
feat(screenshot): add CLI options to cap screenshot size at the source
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. Addresses two related symptoms reported when MCP clients display screenshots inline: 1. The hosted LLM API rejects images exceeding its per-image dimension limits (e.g. Anthropic's 8000x8000 px / 2000x2000 px when >20 images are in the same request). 2. After many captures the cumulative base64 payload pushes the request over the per-call body size limit. Both can be mitigated at the source by reducing format/quality and downscaling the capture. New CLI flags (all opt-in, no behavior change when unset): - --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 when the caller does not specify one. 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 before they are returned. Combines with --screenshot-max-width; the smaller scale wins so both bounds are respected while preserving aspect ratio. Resizing leverages Puppeteer's clip.scale (CDP Page.captureScreenshot) so no new dependencies are introduced. Source dimensions are computed 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 is routed through page.screenshot({clip}) so the scale parameter applies. captureBeyondViewport is left to Puppeteer's default (true when a clip is set), which preserves correct behavior for elements below the fold and for full-page captures. Design notes: - 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. - Fully opt-in: when no flags are set, take_screenshot returns the exact same bytes as before. No breaking change. - The MCP server hardcodes no LLM-specific size limits — operators pick the values that match their client/model combination. This keeps the maintenance surface minimal as model limits evolve and is intended as a complement to, not a replacement for, fixes in the MCP client itself. - Compares against CSS pixels (page.viewport()), not raw bitmap pixels, so HiDPI emulation behaves predictably from the user's perspective. Tests added (6 new): - 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 Refs #879
1 parent 9236834 commit f09e1ea

4 files changed

Lines changed: 423 additions & 98 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,23 @@ The Chrome DevTools MCP server supports the following configuration option:
601601
- **Type:** boolean
602602
- **Default:** `true`
603603

604+
- **`--screenshotFormat`/ `--screenshot-format`**
605+
Override the default output format used by take_screenshot when the caller does not specify one. JPEG and WebP are ~3-5x smaller than PNG, which helps reduce context size in AI conversations. Unset preserves the existing default ("png").
606+
- **Type:** string
607+
- **Choices:** `jpeg`, `png`, `webp`
608+
609+
- **`--screenshotQuality`/ `--screenshot-quality`**
610+
Override the default compression quality (0-100) used by take_screenshot for JPEG and WebP when the caller does not specify one. Lower values mean smaller files. Ignored for PNG. Unset preserves the Puppeteer default.
611+
- **Type:** number
612+
613+
- **`--screenshotMaxWidth`/ `--screenshot-max-width`**
614+
Maximum width in pixels for screenshots. If the captured image is wider, it is downscaled (preserving aspect ratio) before being returned. Reduces context size in AI conversations. Unset means no resize.
615+
- **Type:** number
616+
617+
- **`--screenshotMaxHeight`/ `--screenshot-max-height`**
618+
Maximum height in pixels for screenshots. If the captured image is taller, it is downscaled (preserving aspect ratio) before being returned. Can be combined with --screenshot-max-width; the smaller scale factor wins. Unset means no resize.
619+
- **Type:** number
620+
604621
- **`--slim`**
605622
Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks.
606623
- **Type:** boolean

src/bin/chrome-devtools-mcp-cli-options.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,27 @@ export const cliOptions = {
250250
hidden: true,
251251
describe: 'Include watchdog PID in Clearcut request headers (for testing).',
252252
},
253+
screenshotFormat: {
254+
type: 'string',
255+
description:
256+
'Override the default output format used by take_screenshot when the caller does not specify one. JPEG and WebP are ~3-5x smaller than PNG, which helps reduce context size in AI conversations. Unset preserves the existing default ("png").',
257+
choices: ['jpeg', 'png', 'webp'] as const,
258+
},
259+
screenshotQuality: {
260+
type: 'number',
261+
description:
262+
'Override the default compression quality (0-100) used by take_screenshot for JPEG and WebP when the caller does not specify one. Lower values mean smaller files. Ignored for PNG. Unset preserves the Puppeteer default.',
263+
},
264+
screenshotMaxWidth: {
265+
type: 'number',
266+
description:
267+
'Maximum width in pixels for screenshots. If the captured image is wider, it is downscaled (preserving aspect ratio) before being returned. Reduces context size in AI conversations. Unset means no resize.',
268+
},
269+
screenshotMaxHeight: {
270+
type: 'number',
271+
description:
272+
'Maximum height in pixels for screenshots. If the captured image is taller, it is downscaled (preserving aspect ratio) before being returned. Can be combined with --screenshot-max-width; the smaller scale factor wins. Unset means no resize.',
273+
},
253274
slim: {
254275
type: 'boolean',
255276
describe:

src/tools/screenshot.ts

Lines changed: 222 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -5,101 +5,236 @@
55
*/
66

77
import {zod} from '../third_party/index.js';
8-
import type {ElementHandle, Page} from '../third_party/index.js';
8+
import type {
9+
BoundingBox,
10+
ElementHandle,
11+
Page,
12+
ScreenshotClip,
13+
} from '../third_party/index.js';
914

1015
import {ToolCategory} from './categories.js';
1116
import {definePageTool} from './ToolDefinition.js';
1217

13-
export const screenshot = definePageTool({
14-
name: 'take_screenshot',
15-
description: `Take a screenshot of the page or element.`,
16-
annotations: {
17-
category: ToolCategory.DEBUGGING,
18-
// Not read-only due to filePath param.
19-
readOnlyHint: false,
20-
},
21-
schema: {
22-
format: zod
23-
.enum(['png', 'jpeg', 'webp'])
24-
.default('png')
25-
.describe('Type of format to save the screenshot as. Default is "png"'),
26-
quality: zod
27-
.number()
28-
.min(0)
29-
.max(100)
30-
.optional()
31-
.describe(
32-
'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.',
33-
),
34-
uid: zod
35-
.string()
36-
.optional()
37-
.describe(
38-
'The uid of an element on the page from the page content snapshot. If omitted, takes a page screenshot.',
39-
),
40-
fullPage: zod
41-
.boolean()
42-
.optional()
43-
.describe(
44-
'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
18+
type ScreenshotFormat = 'png' | 'jpeg' | 'webp';
19+
20+
function isScreenshotFormat(value: unknown): value is ScreenshotFormat {
21+
return value === 'png' || value === 'jpeg' || value === 'webp';
22+
}
23+
24+
function isPositiveFiniteNumber(value: number | undefined): value is number {
25+
return value !== undefined && Number.isFinite(value) && value > 0;
26+
}
27+
28+
async function getSourceBox(
29+
page: Page,
30+
element: ElementHandle | undefined,
31+
fullPage: boolean,
32+
): Promise<BoundingBox | undefined> {
33+
if (element) {
34+
const box = await element.boundingBox();
35+
return box ?? undefined;
36+
}
37+
if (fullPage) {
38+
const dims = await page.evaluate(() => ({
39+
width: Math.max(
40+
document.documentElement.scrollWidth,
41+
document.body?.scrollWidth ?? 0,
4542
),
46-
filePath: zod
47-
.string()
48-
.optional()
49-
.describe(
50-
'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
43+
height: Math.max(
44+
document.documentElement.scrollHeight,
45+
document.body?.scrollHeight ?? 0,
5146
),
52-
},
53-
handler: async (request, response, context) => {
54-
if (request.params.uid && request.params.fullPage) {
55-
throw new Error('Providing both "uid" and "fullPage" is not allowed.');
47+
}));
48+
if (dims.width <= 0 || dims.height <= 0) {
49+
return undefined;
5650
}
51+
return {x: 0, y: 0, width: dims.width, height: dims.height};
52+
}
53+
const viewport = page.viewport();
54+
if (!viewport) {
55+
return undefined;
56+
}
57+
return {x: 0, y: 0, width: viewport.width, height: viewport.height};
58+
}
5759

58-
let pageOrHandle: Page | ElementHandle;
59-
if (request.params.uid) {
60-
pageOrHandle = await request.page.getElementByUid(request.params.uid);
61-
} else {
62-
pageOrHandle = request.page.pptrPage;
63-
}
60+
function computeDownscaleClip(
61+
box: BoundingBox,
62+
maxWidth: number | undefined,
63+
maxHeight: number | undefined,
64+
): ScreenshotClip | undefined {
65+
const widthScale = isPositiveFiniteNumber(maxWidth)
66+
? Math.min(1, maxWidth / box.width)
67+
: 1;
68+
const heightScale = isPositiveFiniteNumber(maxHeight)
69+
? Math.min(1, maxHeight / box.height)
70+
: 1;
71+
const scale = Math.min(widthScale, heightScale);
72+
if (scale >= 1) {
73+
return undefined;
74+
}
75+
// Skip degenerate sub-pixel results.
76+
if (Math.round(box.width * scale) < 1 || Math.round(box.height * scale) < 1) {
77+
return undefined;
78+
}
79+
return {
80+
x: box.x,
81+
y: box.y,
82+
width: box.width,
83+
height: box.height,
84+
scale,
85+
};
86+
}
6487

65-
const format = request.params.format;
66-
const quality = format === 'png' ? undefined : request.params.quality;
67-
68-
const screenshot = await pageOrHandle.screenshot({
69-
type: format,
70-
fullPage: request.params.fullPage,
71-
quality,
72-
optimizeForSpeed: true, // Bonus: optimize encoding for speed
73-
});
74-
75-
if (request.params.uid) {
76-
response.appendResponseLine(
77-
`Took a screenshot of node with uid "${request.params.uid}".`,
78-
);
79-
} else if (request.params.fullPage) {
80-
response.appendResponseLine(
81-
'Took a screenshot of the full current page.',
82-
);
83-
} else {
84-
response.appendResponseLine(
85-
"Took a screenshot of the current page's viewport.",
86-
);
87-
}
88+
export const screenshot = definePageTool(args => {
89+
const {
90+
screenshotFormat,
91+
screenshotQuality,
92+
screenshotMaxWidth,
93+
screenshotMaxHeight,
94+
} = args ?? {};
8895

89-
if (request.params.filePath) {
90-
const file = await context.saveFile(screenshot, request.params.filePath);
91-
response.appendResponseLine(`Saved screenshot to ${file.filename}.`);
92-
} else if (screenshot.length >= 2_000_000) {
93-
const {filepath} = await context.saveTemporaryFile(
94-
screenshot,
95-
`screenshot.${request.params.format}`,
96-
);
97-
response.appendResponseLine(`Saved screenshot to ${filepath}.`);
98-
} else {
99-
response.attachImage({
100-
mimeType: `image/${request.params.format}`,
101-
data: Buffer.from(screenshot).toString('base64'),
102-
});
103-
}
104-
},
96+
const defaultFormat: ScreenshotFormat = isScreenshotFormat(screenshotFormat)
97+
? screenshotFormat
98+
: 'png';
99+
const defaultQuality = isPositiveFiniteNumber(screenshotQuality)
100+
? screenshotQuality
101+
: undefined;
102+
const maxWidth = isPositiveFiniteNumber(screenshotMaxWidth)
103+
? screenshotMaxWidth
104+
: undefined;
105+
const maxHeight = isPositiveFiniteNumber(screenshotMaxHeight)
106+
? screenshotMaxHeight
107+
: undefined;
108+
109+
return {
110+
name: 'take_screenshot',
111+
description: `Take a screenshot of the page or element.`,
112+
annotations: {
113+
category: ToolCategory.DEBUGGING,
114+
// Not read-only due to filePath param.
115+
readOnlyHint: false,
116+
},
117+
schema: {
118+
format: zod
119+
.enum(['png', 'jpeg', 'webp'])
120+
.default(defaultFormat)
121+
.describe(
122+
`Type of format to save the screenshot as. Default is "${defaultFormat}"`,
123+
),
124+
quality: zod
125+
.number()
126+
.min(0)
127+
.max(100)
128+
.optional()
129+
.describe(
130+
'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.',
131+
),
132+
uid: zod
133+
.string()
134+
.optional()
135+
.describe(
136+
'The uid of an element on the page from the page content snapshot. If omitted, takes a page screenshot.',
137+
),
138+
fullPage: zod
139+
.boolean()
140+
.optional()
141+
.describe(
142+
'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
143+
),
144+
filePath: zod
145+
.string()
146+
.optional()
147+
.describe(
148+
'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
149+
),
150+
},
151+
handler: async (request, response, context) => {
152+
if (request.params.uid && request.params.fullPage) {
153+
throw new Error('Providing both "uid" and "fullPage" is not allowed.');
154+
}
155+
156+
const page = request.page.pptrPage;
157+
const element = request.params.uid
158+
? await request.page.getElementByUid(request.params.uid)
159+
: undefined;
160+
161+
const format = request.params.format;
162+
const quality =
163+
format === 'png'
164+
? undefined
165+
: (request.params.quality ?? defaultQuality);
166+
const fullPage = request.params.fullPage ?? false;
167+
168+
// Compute downscale clip when maxWidth/maxHeight is set and the source
169+
// exceeds either bound. The smaller scale factor wins so both bounds
170+
// are respected while preserving aspect ratio.
171+
let clip: ScreenshotClip | undefined;
172+
if (maxWidth !== undefined || maxHeight !== undefined) {
173+
const box = await getSourceBox(page, element, fullPage);
174+
if (box) {
175+
clip = computeDownscaleClip(box, maxWidth, maxHeight);
176+
}
177+
}
178+
179+
let screenshot: Uint8Array;
180+
if (clip) {
181+
// page.screenshot with clip lets the CDP scale param downscale the
182+
// capture for viewport, full-page and element shots alike. We rely on
183+
// Puppeteer's default of captureBeyondViewport=true when a clip is
184+
// present so element/full-page captures below the fold still work.
185+
screenshot = await page.screenshot({
186+
type: format,
187+
quality,
188+
optimizeForSpeed: true,
189+
clip,
190+
});
191+
} else if (element) {
192+
screenshot = await element.screenshot({
193+
type: format,
194+
quality,
195+
optimizeForSpeed: true,
196+
});
197+
} else {
198+
screenshot = await page.screenshot({
199+
type: format,
200+
fullPage,
201+
quality,
202+
optimizeForSpeed: true,
203+
});
204+
}
205+
206+
if (request.params.uid) {
207+
response.appendResponseLine(
208+
`Took a screenshot of node with uid "${request.params.uid}".`,
209+
);
210+
} else if (fullPage) {
211+
response.appendResponseLine(
212+
'Took a screenshot of the full current page.',
213+
);
214+
} else {
215+
response.appendResponseLine(
216+
"Took a screenshot of the current page's viewport.",
217+
);
218+
}
219+
220+
if (request.params.filePath) {
221+
const file = await context.saveFile(
222+
screenshot,
223+
request.params.filePath,
224+
);
225+
response.appendResponseLine(`Saved screenshot to ${file.filename}.`);
226+
} else if (screenshot.length >= 2_000_000) {
227+
const {filepath} = await context.saveTemporaryFile(
228+
screenshot,
229+
`screenshot.${request.params.format}`,
230+
);
231+
response.appendResponseLine(`Saved screenshot to ${filepath}.`);
232+
} else {
233+
response.attachImage({
234+
mimeType: `image/${request.params.format}`,
235+
data: Buffer.from(screenshot).toString('base64'),
236+
});
237+
}
238+
},
239+
};
105240
});

0 commit comments

Comments
 (0)