Skip to content

Commit b0ce08a

Browse files
feat: support saving snapshots to file (#463)
Refs #153 --------- Co-authored-by: Nikolay Vitkov <[email protected]>
1 parent d177087 commit b0ce08a

File tree

8 files changed

+107
-38
lines changed

8 files changed

+107
-38
lines changed

docs/tool-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over
342342

343343
**Parameters:**
344344

345+
- **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.
345346
- **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false.
346347

347348
---

src/McpResponse.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,17 @@ import type {
2424
TextContent,
2525
} from './third_party/index.js';
2626
import {handleDialog} from './tools/pages.js';
27-
import type {ImageContentData, Response} from './tools/ToolDefinition.js';
27+
import type {
28+
ImageContentData,
29+
Response,
30+
SnapshotParams,
31+
} from './tools/ToolDefinition.js';
2832
import {paginate} from './utils/pagination.js';
2933
import type {PaginationOptions} from './utils/types.js';
3034

3135
export class McpResponse implements Response {
3236
#includePages = false;
33-
#includeSnapshot = false;
34-
#includeVerboseSnapshot = false;
37+
#snapshotParams?: SnapshotParams;
3538
#attachedNetworkRequestId?: number;
3639
#attachedConsoleMessageId?: number;
3740
#textResponseLines: string[] = [];
@@ -53,9 +56,10 @@ export class McpResponse implements Response {
5356
this.#includePages = value;
5457
}
5558

56-
setIncludeSnapshot(value: boolean, verbose = false): void {
57-
this.#includeSnapshot = value;
58-
this.#includeVerboseSnapshot = verbose;
59+
includeSnapshot(params?: SnapshotParams): void {
60+
this.#snapshotParams = params ?? {
61+
verbose: false,
62+
};
5963
}
6064

6165
setIncludeNetworkRequests(
@@ -158,12 +162,8 @@ export class McpResponse implements Response {
158162
return this.#images;
159163
}
160164

161-
get includeSnapshot(): boolean {
162-
return this.#includeSnapshot;
163-
}
164-
165-
get includeVersboseSnapshot(): boolean {
166-
return this.#includeVerboseSnapshot;
165+
get snapshotParams(): SnapshotParams | undefined {
166+
return this.#snapshotParams;
167167
}
168168

169169
async handle(
@@ -173,8 +173,22 @@ export class McpResponse implements Response {
173173
if (this.#includePages) {
174174
await context.createPagesSnapshot();
175175
}
176-
if (this.#includeSnapshot) {
177-
await context.createTextSnapshot(this.#includeVerboseSnapshot);
176+
177+
let formattedSnapshot: string | undefined;
178+
if (this.#snapshotParams) {
179+
await context.createTextSnapshot(this.#snapshotParams.verbose);
180+
const snapshot = context.getTextSnapshot();
181+
if (snapshot) {
182+
if (this.#snapshotParams.filePath) {
183+
await context.saveFile(
184+
new TextEncoder().encode(formatA11ySnapshot(snapshot.root)),
185+
this.#snapshotParams.filePath,
186+
);
187+
formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`;
188+
} else {
189+
formattedSnapshot = formatA11ySnapshot(snapshot.root);
190+
}
191+
}
178192
}
179193

180194
const bodies: {
@@ -281,6 +295,7 @@ export class McpResponse implements Response {
281295
bodies,
282296
consoleData,
283297
consoleListData,
298+
formattedSnapshot,
284299
});
285300
}
286301

@@ -294,6 +309,7 @@ export class McpResponse implements Response {
294309
};
295310
consoleData: ConsoleMessageData | undefined;
296311
consoleListData: ConsoleMessageData[] | undefined;
312+
formattedSnapshot: string | undefined;
297313
},
298314
): Array<TextContent | ImageContent> {
299315
const response = [`# ${toolName} response`];
@@ -339,13 +355,9 @@ Call ${handleDialog.name} to handle it before continuing.`);
339355
response.push(...parts);
340356
}
341357

342-
if (this.#includeSnapshot) {
343-
const snapshot = context.getTextSnapshot();
344-
if (snapshot) {
345-
const formattedSnapshot = formatA11ySnapshot(snapshot.root);
346-
response.push('## Page content');
347-
response.push(formattedSnapshot);
348-
}
358+
if (data.formattedSnapshot) {
359+
response.push('## Page content');
360+
response.push(data.formattedSnapshot);
349361
}
350362

351363
response.push(...this.#formatNetworkRequestData(context, data.bodies));

src/tools/ToolDefinition.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export interface ImageContentData {
4242
mimeType: string;
4343
}
4444

45+
export interface SnapshotParams {
46+
verbose?: boolean;
47+
filePath?: string;
48+
}
49+
4550
export interface Response {
4651
appendResponseLine(value: string): void;
4752
setIncludePages(value: boolean): void;
@@ -59,8 +64,7 @@ export interface Response {
5964
includePreservedMessages?: boolean;
6065
},
6166
): void;
62-
setIncludeSnapshot(value: boolean): void;
63-
setIncludeSnapshot(value: boolean, verbose?: boolean): void;
67+
includeSnapshot(params?: SnapshotParams): void;
6468
attachImage(value: ImageContentData): void;
6569
attachNetworkRequest(reqid: number): void;
6670
attachConsoleMessage(msgid: number): void;

src/tools/input.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const click = defineTool({
4444
? `Successfully double clicked on the element`
4545
: `Successfully clicked on the element`,
4646
);
47-
response.setIncludeSnapshot(true);
47+
response.includeSnapshot();
4848
} finally {
4949
void handle.dispose();
5050
}
@@ -73,7 +73,7 @@ export const hover = defineTool({
7373
await handle.asLocator().hover();
7474
});
7575
response.appendResponseLine(`Successfully hovered over the element`);
76-
response.setIncludeSnapshot(true);
76+
response.includeSnapshot();
7777
} finally {
7878
void handle.dispose();
7979
}
@@ -159,7 +159,7 @@ export const fill = defineTool({
159159
);
160160
});
161161
response.appendResponseLine(`Successfully filled out the element`);
162-
response.setIncludeSnapshot(true);
162+
response.includeSnapshot();
163163
},
164164
});
165165

@@ -184,7 +184,7 @@ export const drag = defineTool({
184184
await toHandle.drop(fromHandle);
185185
});
186186
response.appendResponseLine(`Successfully dragged an element`);
187-
response.setIncludeSnapshot(true);
187+
response.includeSnapshot();
188188
} finally {
189189
void fromHandle.dispose();
190190
void toHandle.dispose();
@@ -220,7 +220,7 @@ export const fillForm = defineTool({
220220
});
221221
}
222222
response.appendResponseLine(`Successfully filled out the form`);
223-
response.setIncludeSnapshot(true);
223+
response.includeSnapshot();
224224
},
225225
});
226226

@@ -264,7 +264,7 @@ export const uploadFile = defineTool({
264264
);
265265
}
266266
}
267-
response.setIncludeSnapshot(true);
267+
response.includeSnapshot();
268268
response.appendResponseLine(`File uploaded from ${filePath}.`);
269269
} finally {
270270
void handle.dispose();
@@ -304,6 +304,6 @@ export const pressKey = defineTool({
304304
response.appendResponseLine(
305305
`Successfully pressed key: ${request.params.key}`,
306306
);
307-
response.setIncludeSnapshot(true);
307+
response.includeSnapshot();
308308
},
309309
});

src/tools/screenshot.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export const screenshot = defineTool({
1515
description: `Take a screenshot of the page or element.`,
1616
annotations: {
1717
category: ToolCategory.DEBUGGING,
18-
readOnlyHint: true,
18+
// Not read-only due to filePath param.
19+
readOnlyHint: false,
1920
},
2021
schema: {
2122
format: zod

src/tools/snapshot.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export const takeSnapshot = defineTool({
1515
identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot.`,
1616
annotations: {
1717
category: ToolCategory.DEBUGGING,
18-
readOnlyHint: true,
18+
// Not read-only due to filePath param.
19+
readOnlyHint: false,
1920
},
2021
schema: {
2122
verbose: zod
@@ -24,9 +25,18 @@ identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over
2425
.describe(
2526
'Whether to include all possible information available in the full a11y tree. Default is false.',
2627
),
28+
filePath: zod
29+
.string()
30+
.optional()
31+
.describe(
32+
'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.',
33+
),
2734
},
2835
handler: async (request, response) => {
29-
response.setIncludeSnapshot(true, request.params.verbose ?? false);
36+
response.includeSnapshot({
37+
verbose: request.params.verbose ?? false,
38+
filePath: request.params.filePath,
39+
});
3040
},
3141
});
3242

@@ -48,6 +58,6 @@ export const waitFor = defineTool({
4858
`Element with text "${request.params.text}" found.`,
4959
);
5060

51-
response.setIncludeSnapshot(true);
61+
response.includeSnapshot();
5262
},
5363
});

tests/McpResponse.test.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66
import assert from 'node:assert';
7+
import {readFile, rm} from 'node:fs/promises';
8+
import {tmpdir} from 'node:os';
9+
import {join} from 'node:path';
710
import {describe, it} from 'node:test';
811

912
import {getMockRequest, getMockResponse, html, withBrowser} from './utils.js';
@@ -54,7 +57,7 @@ Testing 2`,
5457
await page.setContent(`<!DOCTYPE html>
5558
<button>Click me</button><input type="text" value="Input">`);
5659
await page.focus('button');
57-
response.setIncludeSnapshot(true);
60+
response.includeSnapshot();
5861
const result = await response.handle('test', context);
5962
assert.equal(result[0].type, 'text');
6063
assert.strictEqual(
@@ -80,7 +83,7 @@ uid=1_0 RootWebArea
8083
/></label>`,
8184
);
8285
await page.focus('input');
83-
response.setIncludeSnapshot(true);
86+
response.includeSnapshot();
8487
const result = await response.handle('test', context);
8588
assert.equal(result[0].type, 'text');
8689
assert.strictEqual(
@@ -99,7 +102,9 @@ uid=1_0 RootWebArea "My test page"
99102
await withBrowser(async (response, context) => {
100103
const page = context.getSelectedPage();
101104
await page.setContent(html`<aside>test</aside>`);
102-
response.setIncludeSnapshot(true, true);
105+
response.includeSnapshot({
106+
verbose: true,
107+
});
103108
const result = await response.handle('test', context);
104109
assert.equal(result[0].type, 'text');
105110
assert.strictEqual(
@@ -117,6 +122,42 @@ uid=1_0 RootWebArea "My test page"
117122
});
118123
});
119124

125+
it('saves snapshot to file', async () => {
126+
const filePath = join(tmpdir(), 'test-screenshot.png');
127+
try {
128+
await withBrowser(async (response, context) => {
129+
const page = context.getSelectedPage();
130+
await page.setContent(html`<aside>test</aside>`);
131+
response.includeSnapshot({
132+
verbose: true,
133+
filePath,
134+
});
135+
const result = await response.handle('test', context);
136+
assert.equal(result[0].type, 'text');
137+
console.log(result[0].text);
138+
assert.strictEqual(
139+
result[0].text,
140+
`# test response
141+
## Page content
142+
Saved snapshot to ${filePath}.`,
143+
);
144+
});
145+
const content = await readFile(filePath, 'utf-8');
146+
assert.strictEqual(
147+
content,
148+
`uid=1_0 RootWebArea "My test page"
149+
uid=1_1 ignored
150+
uid=1_2 ignored
151+
uid=1_3 complementary
152+
uid=1_4 StaticText "test"
153+
uid=1_5 InlineTextBox "test"
154+
`,
155+
);
156+
} finally {
157+
await rm(filePath, {force: true});
158+
}
159+
});
160+
120161
it('adds throttling setting when it is not null', async () => {
121162
await withBrowser(async (response, context) => {
122163
context.setNetworkConditions('Slow 3G');

tests/tools/input.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ describe('input', () => {
427427
);
428428

429429
assert.strictEqual(response.responseLines.length, 0);
430-
assert.strictEqual(response.includeSnapshot, false);
430+
assert.strictEqual(response.snapshotParams, undefined);
431431

432432
await fs.unlink(testFilePath);
433433
});

0 commit comments

Comments
 (0)