From b28d0171012b5c279f6f18b1695a32f5e3d41237 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Thu, 23 Oct 2025 21:49:14 +0200 Subject: [PATCH 1/2] feat: support saving snapshots to file --- docs/tool-reference.md | 1 + src/McpResponse.ts | 54 ++++++++++++++++++++++--------------- src/tools/ToolDefinition.ts | 8 ++++-- src/tools/input.ts | 14 +++++----- src/tools/snapshot.ts | 13 +++++++-- tests/McpResponse.test.ts | 47 +++++++++++++++++++++++++++++--- tests/tools/input.test.ts | 2 +- 7 files changed, 103 insertions(+), 36 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index a956e1ece..7e51181bc 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -342,6 +342,7 @@ identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over **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. - **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 8102aeb3b..d97c3d054 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -24,14 +24,17 @@ import type { TextContent, } from './third_party/index.js'; import {handleDialog} from './tools/pages.js'; -import type {ImageContentData, Response} from './tools/ToolDefinition.js'; +import type { + ImageContentData, + Response, + SnapshotParams, +} from './tools/ToolDefinition.js'; import {paginate} from './utils/pagination.js'; import type {PaginationOptions} from './utils/types.js'; export class McpResponse implements Response { #includePages = false; - #includeSnapshot = false; - #includeVerboseSnapshot = false; + #snapshotParams?: SnapshotParams; #attachedNetworkRequestId?: number; #attachedConsoleMessageId?: number; #textResponseLines: string[] = []; @@ -53,9 +56,10 @@ export class McpResponse implements Response { this.#includePages = value; } - setIncludeSnapshot(value: boolean, verbose = false): void { - this.#includeSnapshot = value; - this.#includeVerboseSnapshot = verbose; + includeSnapshot(params?: SnapshotParams): void { + this.#snapshotParams = params ?? { + verbose: false, + }; } setIncludeNetworkRequests( @@ -158,12 +162,8 @@ export class McpResponse implements Response { return this.#images; } - get includeSnapshot(): boolean { - return this.#includeSnapshot; - } - - get includeVersboseSnapshot(): boolean { - return this.#includeVerboseSnapshot; + get snapshotParams(): SnapshotParams | undefined { + return this.#snapshotParams; } async handle( @@ -173,8 +173,22 @@ export class McpResponse implements Response { if (this.#includePages) { await context.createPagesSnapshot(); } - if (this.#includeSnapshot) { - await context.createTextSnapshot(this.#includeVerboseSnapshot); + + let formattedSnapshot: string | undefined; + if (this.#snapshotParams) { + await context.createTextSnapshot(this.#snapshotParams.verbose); + const snapshot = context.getTextSnapshot(); + if (snapshot) { + if (this.#snapshotParams.filePath) { + await context.saveFile( + new TextEncoder().encode(formatA11ySnapshot(snapshot.root)), + this.#snapshotParams.filePath, + ); + formattedSnapshot = `Saved screenshot to ${this.#snapshotParams.filePath}.`; + } else { + formattedSnapshot = formatA11ySnapshot(snapshot.root); + } + } } const bodies: { @@ -281,6 +295,7 @@ export class McpResponse implements Response { bodies, consoleData, consoleListData, + formattedSnapshot, }); } @@ -294,6 +309,7 @@ export class McpResponse implements Response { }; consoleData: ConsoleMessageData | undefined; consoleListData: ConsoleMessageData[] | undefined; + formattedSnapshot: string | undefined; }, ): Array { const response = [`# ${toolName} response`]; @@ -339,13 +355,9 @@ Call ${handleDialog.name} to handle it before continuing.`); response.push(...parts); } - if (this.#includeSnapshot) { - const snapshot = context.getTextSnapshot(); - if (snapshot) { - const formattedSnapshot = formatA11ySnapshot(snapshot.root); - response.push('## Page content'); - response.push(formattedSnapshot); - } + if (data.formattedSnapshot) { + response.push('## Page content'); + response.push(data.formattedSnapshot); } response.push(...this.#formatNetworkRequestData(context, data.bodies)); diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 8804e0dc3..68f292603 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -42,6 +42,11 @@ export interface ImageContentData { mimeType: string; } +export interface SnapshotParams { + verbose?: boolean; + filePath?: string; +} + export interface Response { appendResponseLine(value: string): void; setIncludePages(value: boolean): void; @@ -59,8 +64,7 @@ export interface Response { includePreservedMessages?: boolean; }, ): void; - setIncludeSnapshot(value: boolean): void; - setIncludeSnapshot(value: boolean, verbose?: boolean): void; + includeSnapshot(params?: SnapshotParams): void; attachImage(value: ImageContentData): void; attachNetworkRequest(reqid: number): void; attachConsoleMessage(msgid: number): void; diff --git a/src/tools/input.ts b/src/tools/input.ts index 6e4094dcc..505568922 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -44,7 +44,7 @@ export const click = defineTool({ ? `Successfully double clicked on the element` : `Successfully clicked on the element`, ); - response.setIncludeSnapshot(true); + response.includeSnapshot(); } finally { void handle.dispose(); } @@ -73,7 +73,7 @@ export const hover = defineTool({ await handle.asLocator().hover(); }); response.appendResponseLine(`Successfully hovered over the element`); - response.setIncludeSnapshot(true); + response.includeSnapshot(); } finally { void handle.dispose(); } @@ -159,7 +159,7 @@ export const fill = defineTool({ ); }); response.appendResponseLine(`Successfully filled out the element`); - response.setIncludeSnapshot(true); + response.includeSnapshot(); }, }); @@ -184,7 +184,7 @@ export const drag = defineTool({ await toHandle.drop(fromHandle); }); response.appendResponseLine(`Successfully dragged an element`); - response.setIncludeSnapshot(true); + response.includeSnapshot(); } finally { void fromHandle.dispose(); void toHandle.dispose(); @@ -220,7 +220,7 @@ export const fillForm = defineTool({ }); } response.appendResponseLine(`Successfully filled out the form`); - response.setIncludeSnapshot(true); + response.includeSnapshot(); }, }); @@ -264,7 +264,7 @@ export const uploadFile = defineTool({ ); } } - response.setIncludeSnapshot(true); + response.includeSnapshot(); response.appendResponseLine(`File uploaded from ${filePath}.`); } finally { void handle.dispose(); @@ -304,6 +304,6 @@ export const pressKey = defineTool({ response.appendResponseLine( `Successfully pressed key: ${request.params.key}`, ); - response.setIncludeSnapshot(true); + response.includeSnapshot(); }, }); diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 9b108d302..10a94636c 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -24,9 +24,18 @@ identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over .describe( 'Whether to include all possible information available in the full a11y tree. Default is false.', ), + filePath: zod + .string() + .optional() + .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.', + ), }, handler: async (request, response) => { - response.setIncludeSnapshot(true, request.params.verbose ?? false); + response.includeSnapshot({ + verbose: request.params.verbose ?? false, + filePath: request.params.filePath, + }); }, }); @@ -48,6 +57,6 @@ export const waitFor = defineTool({ `Element with text "${request.params.text}" found.`, ); - response.setIncludeSnapshot(true); + response.includeSnapshot(); }, }); diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index e3a174c92..5facda166 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; +import {readFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import {describe, it} from 'node:test'; import {getMockRequest, getMockResponse, html, withBrowser} from './utils.js'; @@ -54,7 +57,7 @@ Testing 2`, await page.setContent(` `); await page.focus('button'); - response.setIncludeSnapshot(true); + response.includeSnapshot(); const result = await response.handle('test', context); assert.equal(result[0].type, 'text'); assert.strictEqual( @@ -80,7 +83,7 @@ uid=1_0 RootWebArea />`, ); await page.focus('input'); - response.setIncludeSnapshot(true); + response.includeSnapshot(); const result = await response.handle('test', context); assert.equal(result[0].type, 'text'); assert.strictEqual( @@ -99,7 +102,9 @@ uid=1_0 RootWebArea "My test page" await withBrowser(async (response, context) => { const page = context.getSelectedPage(); await page.setContent(html``); - response.setIncludeSnapshot(true, true); + response.includeSnapshot({ + verbose: true, + }); const result = await response.handle('test', context); assert.equal(result[0].type, 'text'); assert.strictEqual( @@ -117,6 +122,42 @@ uid=1_0 RootWebArea "My test page" }); }); + it('saves snapshot to file', async () => { + const filePath = join(tmpdir(), 'test-screenshot.png'); + try { + await withBrowser(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent(html``); + response.includeSnapshot({ + verbose: true, + filePath, + }); + const result = await response.handle('test', context); + assert.equal(result[0].type, 'text'); + console.log(result[0].text); + assert.strictEqual( + result[0].text, + `# test response +## Page content +Saved screenshot to ${filePath}.`, + ); + }); + const content = await readFile(filePath, 'utf-8'); + assert.strictEqual( + content, + `uid=1_0 RootWebArea "My test page" + uid=1_1 ignored + uid=1_2 ignored + uid=1_3 complementary + uid=1_4 StaticText "test" + uid=1_5 InlineTextBox "test" +`, + ); + } finally { + await rm(filePath, {force: true}); + } + }); + it('adds throttling setting when it is not null', async () => { await withBrowser(async (response, context) => { context.setNetworkConditions('Slow 3G'); diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 8a6211739..d819d80a5 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -427,7 +427,7 @@ describe('input', () => { ); assert.strictEqual(response.responseLines.length, 0); - assert.strictEqual(response.includeSnapshot, false); + assert.strictEqual(response.snapshotParams, undefined); await fs.unlink(testFilePath); }); From f04d13a8b4a33125409610db2de1ee660ab888f4 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Fri, 24 Oct 2025 11:11:38 +0200 Subject: [PATCH 2/2] Update src/McpResponse.ts Co-authored-by: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com> --- src/McpResponse.ts | 2 +- src/tools/screenshot.ts | 3 ++- src/tools/snapshot.ts | 3 ++- tests/McpResponse.test.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/McpResponse.ts b/src/McpResponse.ts index d97c3d054..24f2b3f8a 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -184,7 +184,7 @@ export class McpResponse implements Response { new TextEncoder().encode(formatA11ySnapshot(snapshot.root)), this.#snapshotParams.filePath, ); - formattedSnapshot = `Saved screenshot to ${this.#snapshotParams.filePath}.`; + formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`; } else { formattedSnapshot = formatA11ySnapshot(snapshot.root); } diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 9f38b11bd..d8d3dfcfc 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -15,7 +15,8 @@ export const screenshot = defineTool({ description: `Take a screenshot of the page or element.`, annotations: { category: ToolCategory.DEBUGGING, - readOnlyHint: true, + // Not read-only due to filePath param. + readOnlyHint: false, }, schema: { format: zod diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 10a94636c..0187f7fc7 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -15,7 +15,8 @@ export const takeSnapshot = defineTool({ identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot.`, annotations: { category: ToolCategory.DEBUGGING, - readOnlyHint: true, + // Not read-only due to filePath param. + readOnlyHint: false, }, schema: { verbose: zod diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 5facda166..29dad971d 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -139,7 +139,7 @@ uid=1_0 RootWebArea "My test page" result[0].text, `# test response ## Page content -Saved screenshot to ${filePath}.`, +Saved snapshot to ${filePath}.`, ); }); const content = await readFile(filePath, 'utf-8');