feat: Add snapshot option to every browser use interaction tool (#36111)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dimitri Lavrenük
2026-08-12 12:24:33 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent 01ce4490d0
commit 7f3713702a
7 changed files with 426 additions and 19 deletions
+10 -4
View File
@@ -24,6 +24,7 @@ export {
elementTargetSchema,
modalStateSchema,
pageIdField,
snapshotField,
withSnapshotEnvelope,
} from './schemas';
export type { ElementTargetInput } from './schemas';
@@ -32,7 +33,7 @@ export type { ElementTargetInput } from './schemas';
// Connected tool input constraint — every tool must have at least pageId
// ---------------------------------------------------------------------------
type ConnectedToolInput = { pageId?: string };
type ConnectedToolInput = { pageId?: string; snapshot?: 'interactive' | 'non-interactive' };
// ---------------------------------------------------------------------------
// Connected tool options
@@ -41,6 +42,8 @@ type ConnectedToolInput = { pageId?: string };
export interface ConnectedToolOptions {
/** Append an accessibility snapshot to the response after the action. */
autoSnapshot?: boolean;
/** Annotate the auto-snapshot with interactive refs. Defaults to the adapter default (true). */
snapshotInteractive?: boolean;
/** Wrap the action in waitForCompletion (network/navigation settle). */
waitForCompletion?: boolean;
/** Skip post-action enrichment (snapshot, tab diff, etc.). Use for destructive actions like tab close. */
@@ -91,6 +94,9 @@ export function createConnectedTool<
inputSchema,
outputSchema,
async execute(args: z.infer<TSchema>, context: ToolContext) {
const effectiveOptions: ConnectedToolOptions = args.snapshot
? { ...options, autoSnapshot: true, snapshotInteractive: args.snapshot === 'interactive' }
: (options ?? {});
try {
const { state, pageId } = resolvePageContext(connection, args);
@@ -111,7 +117,7 @@ export function createConnectedTool<
if (!options?.skipEnrichment) {
// Re-resolve: tab-creating actions (tab_open) update activePageId
const enrichPageId = state.activePageId || pageId;
await enrichResponse(result, state, enrichPageId, options ?? {}, tabsBefore);
await enrichResponse(result, state, enrichPageId, effectiveOptions, tabsBefore);
}
// Sync live URL back to state.pages so the cache stays fresh
const currentUrl = state.adapter.getPageUrl(pageId);
@@ -130,12 +136,12 @@ export function createConnectedTool<
new ConnectionLostError('browser_closed'),
connection,
args,
options ?? {},
effectiveOptions,
),
);
}
return redactCallToolResult(
await buildErrorResponse(error, connection, args, options ?? {}),
await buildErrorResponse(error, connection, args, effectiveOptions),
);
}
},
@@ -536,4 +536,68 @@ describe('createInteractionTools', () => {
});
});
});
// -----------------------------------------------------------------------
// snapshot input param (shared via createConnectedTool)
// -----------------------------------------------------------------------
describe('snapshot input param', () => {
const validInputs: Record<string, Record<string, unknown>> = {
browser_click: { element: { ref: 'e1' } },
browser_type: { element: { ref: 'e1' }, text: 'hi' },
browser_select: { element: { ref: 'e1' }, values: ['a'] },
browser_drag: { from: { ref: 'e1' }, to: { ref: 'e2' } },
browser_hover: { element: { ref: 'e1' } },
browser_press: { keys: 'Enter' },
browser_scroll: { mode: 'direction', direction: 'down' },
browser_upload: { files: ['/tmp/a.txt'] },
browser_dialog: { action: 'accept' },
};
it.each(Object.keys(validInputs))('%s accepts snapshot values and rejects invalid', (name) => {
const schema = findTool(tools, name).inputSchema;
expect(() => schema.parse({ ...validInputs[name], snapshot: 'interactive' })).not.toThrow();
expect(() =>
schema.parse({ ...validInputs[name], snapshot: 'non-interactive' }),
).not.toThrow();
expect(() => schema.parse({ ...validInputs[name], snapshot: 'full' })).toThrow();
});
it('returns an interactive snapshot when snapshot is "interactive"', async () => {
mockConnection.adapter.snapshot.mockResolvedValue({ tree: '- button "OK"', refCount: 1 });
const result = await findTool(tools, 'browser_click').execute(
{ element: { ref: 'e1' }, snapshot: 'interactive' },
TOOL_CONTEXT,
);
const data = structuredOf(result);
expect(mockConnection.adapter.snapshot).toHaveBeenCalledWith('page1', undefined, true);
expect(data.snapshot).toBe('- button "OK"');
});
it('returns a plain snapshot when snapshot is "non-interactive"', async () => {
mockConnection.adapter.snapshot.mockResolvedValue({ tree: '- text "Done"', refCount: 0 });
const result = await findTool(tools, 'browser_click').execute(
{ element: { ref: 'e1' }, snapshot: 'non-interactive' },
TOOL_CONTEXT,
);
const data = structuredOf(result);
expect(mockConnection.adapter.snapshot).toHaveBeenCalledWith('page1', undefined, false);
expect(data.snapshot).toBe('- text "Done"');
});
it('does not snapshot when the param is omitted', async () => {
const result = await findTool(tools, 'browser_click').execute(
{ element: { ref: 'e1' } },
TOOL_CONTEXT,
);
const data = structuredOf(result);
expect(mockConnection.adapter.snapshot).not.toHaveBeenCalled();
expect(data.snapshot).toBeUndefined();
});
});
});
@@ -7,6 +7,7 @@ import {
createConnectedTool,
elementTargetSchema,
pageIdField,
snapshotField,
withSnapshotEnvelope,
} from './helpers';
@@ -41,6 +42,7 @@ const browserClickSchema = z
.optional()
.describe('Modifier keys to hold'),
pageId: pageIdField,
snapshot: snapshotField,
})
.describe('Click an element');
@@ -83,6 +85,7 @@ const browserTypeSchema = z
submit: z.boolean().optional().describe('Press Enter after typing'),
delay: z.number().optional().describe('Delay between keystrokes in ms'),
pageId: pageIdField,
snapshot: snapshotField,
})
.describe('Type text into an element');
@@ -124,6 +127,7 @@ const browserSelectSchema = z
element: elementTargetSchema.describe('Select element to interact with'),
values: z.array(z.string()).describe('Option values or labels to select'),
pageId: pageIdField,
snapshot: snapshotField,
})
.describe('Select option(s) in a <select> element');
@@ -155,6 +159,7 @@ const browserDragSchema = z
from: elementTargetSchema.describe('Source element to drag from'),
to: elementTargetSchema.describe('Target element to drag to'),
pageId: pageIdField,
snapshot: snapshotField,
})
.describe('Drag from one element to another');
@@ -185,6 +190,7 @@ const browserHoverSchema = z
.object({
element: elementTargetSchema.describe('Element to hover over'),
pageId: pageIdField,
snapshot: snapshotField,
})
.describe('Hover over an element');
@@ -215,6 +221,7 @@ const browserPressSchema = z
.object({
keys: z.string().describe('Key or key combination (e.g. "Enter", "Control+A")'),
pageId: pageIdField,
snapshot: snapshotField,
})
.describe('Press keyboard key(s)');
@@ -245,6 +252,7 @@ const scrollToElementSchema = z.object({
mode: z.literal('element').describe('Scroll an element into view'),
element: elementTargetSchema.describe('Element to scroll into view'),
pageId: pageIdField,
snapshot: snapshotField,
});
const scrollByDirectionSchema = z.object({
@@ -252,6 +260,7 @@ const scrollByDirectionSchema = z.object({
direction: z.enum(['up', 'down']).describe('Scroll direction'),
amount: z.number().optional().describe('Pixels to scroll (default: 500)'),
pageId: pageIdField,
snapshot: snapshotField,
});
const browserScrollSchema = z
@@ -297,6 +306,7 @@ const browserUploadSchema = z
.describe('File input element (not needed when a file chooser dialog is pending)'),
files: z.array(z.string()).describe('Absolute file paths to upload'),
pageId: pageIdField,
snapshot: snapshotField,
})
.describe('Set files on a file input element or fulfill a pending file chooser dialog');
@@ -329,6 +339,7 @@ const browserDialogSchema = z
action: z.enum(['accept', 'dismiss']).describe('How to handle the dialog'),
text: z.string().optional().describe('Text to enter (for prompt dialogs)'),
pageId: pageIdField,
snapshot: snapshotField,
})
.describe('Handle a JavaScript dialog');
@@ -0,0 +1,301 @@
import { McpBrowserError } from '../errors';
import { buildErrorResponse, enrichResponse, resolvePageContext } from './response-envelope';
import { createMockConnection } from './test-helpers';
import { analyzeHtmlSensitivity } from '../sensitivity/analyze-html';
import type { CallToolResult } from '../types';
vi.mock('../sensitivity/analyze-html', () => ({
analyzeHtmlSensitivity: vi.fn(),
}));
const analyzeMock = vi.mocked(analyzeHtmlSensitivity);
function makeResult(structured: Record<string, unknown>): CallToolResult {
return {
content: [{ type: 'text', text: JSON.stringify(structured) }],
structuredContent: structured,
};
}
function structuredOf(result: CallToolResult): Record<string, unknown> {
return result.structuredContent as Record<string, unknown>;
}
describe('resolvePageContext', () => {
it('uses the pageId from args when given', () => {
const { connection, state } = createMockConnection();
expect(resolvePageContext(connection, { pageId: 'page7' })).toEqual({
state,
pageId: 'page7',
});
});
it('defaults to the active page', () => {
const { connection, state } = createMockConnection();
expect(resolvePageContext(connection, {})).toEqual({ state, pageId: 'page1' });
});
});
describe('enrichResponse', () => {
let mockConnection: ReturnType<typeof createMockConnection>;
beforeEach(() => {
mockConnection = createMockConnection();
analyzeMock.mockReturnValue({ ok: true, sensitive: false, hits: [] });
});
it('does nothing when the result has no structuredContent', async () => {
const result: CallToolResult = { content: [{ type: 'text', text: 'ok' }] };
await enrichResponse(result, mockConnection.state, 'page1', { autoSnapshot: true });
expect(mockConnection.adapter.snapshot).not.toHaveBeenCalled();
expect(result.structuredContent).toBeUndefined();
});
it('does not snapshot without autoSnapshot', async () => {
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', {});
expect(mockConnection.adapter.snapshot).not.toHaveBeenCalled();
expect(structuredOf(result).snapshot).toBeUndefined();
});
it('attaches a snapshot with autoSnapshot', async () => {
mockConnection.adapter.snapshot.mockResolvedValue({ tree: '- button "OK"', refCount: 1 });
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', { autoSnapshot: true });
expect(mockConnection.adapter.snapshot).toHaveBeenCalledWith('page1', undefined, undefined);
expect(structuredOf(result)).toMatchObject({ clicked: true, snapshot: '- button "OK"' });
});
it.each([
['interactive', true],
['non-interactive', false],
])('passes the %s flag through to the adapter', async (_label, interactive) => {
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', {
autoSnapshot: true,
snapshotInteractive: interactive,
});
expect(mockConnection.adapter.snapshot).toHaveBeenCalledWith('page1', undefined, interactive);
});
it('keeps the original result when the snapshot fails', async () => {
mockConnection.adapter.snapshot.mockRejectedValue(new Error('page gone'));
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', { autoSnapshot: true });
expect(structuredOf(result)).toEqual({ clicked: true });
});
it('redacts detected secrets from the snapshot', async () => {
mockConnection.adapter.snapshot.mockResolvedValue({
tree: '- text "your key: sk-SECRET123"',
refCount: 0,
});
analyzeMock.mockReturnValue({
ok: true,
sensitive: true,
hits: [{ type: 'secret', value: 'sk-SECRET123' }],
});
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', { autoSnapshot: true });
const snapshot = structuredOf(result).snapshot as string;
expect(snapshot).not.toContain('sk-SECRET123');
expect(snapshot).toContain('your key: ');
});
it('probes sensitivity and redacts content results without autoSnapshot', async () => {
analyzeMock.mockReturnValue({
ok: true,
sensitive: true,
hits: [{ type: 'secret', value: 'sk-SECRET123' }],
});
const result = makeResult({ content: 'the key is sk-SECRET123' });
await enrichResponse(result, mockConnection.state, 'page1', {});
expect(mockConnection.adapter.probePageHtml).toHaveBeenCalledWith('page1');
expect(structuredOf(result).content).not.toContain('sk-SECRET123');
});
it('attaches modal states when present', async () => {
const modal = {
type: 'dialog' as const,
description: 'alert: hi',
clearedBy: 'browser_dialog',
};
mockConnection.adapter.getModalStates.mockReturnValue([modal]);
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', {});
expect(structuredOf(result).modalStates).toEqual([modal]);
});
it('attaches the console summary when there are errors or warnings', async () => {
mockConnection.adapter.getConsoleSummary.mockReturnValue({ errors: 2, warnings: 1 });
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', { autoSnapshot: true });
expect(structuredOf(result).consoleSummary).toEqual({ errors: 2, warnings: 1 });
});
it('omits a clean console summary', async () => {
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', { autoSnapshot: true });
expect(structuredOf(result).consoleSummary).toBeUndefined();
});
it('reports tabs opened by the action', async () => {
mockConnection.adapter.listTabs.mockResolvedValue([
{ id: 'page1', title: 'Test Page', url: 'http://test.com' },
{ id: 'page2', title: 'Popup', url: 'http://test.com/popup' },
]);
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', {}, new Set(['page1']));
expect(structuredOf(result).newTabs).toEqual([
{ id: 'page2', title: 'Popup', url: 'http://test.com/popup' },
]);
});
it('omits newTabs when no tab was opened', async () => {
const result = makeResult({ clicked: true });
await enrichResponse(result, mockConnection.state, 'page1', {}, new Set(['page1']));
expect(structuredOf(result).newTabs).toBeUndefined();
});
});
describe('buildErrorResponse', () => {
let mockConnection: ReturnType<typeof createMockConnection>;
beforeEach(() => {
mockConnection = createMockConnection();
analyzeMock.mockReturnValue({ ok: true, sensitive: false, hits: [] });
});
it('returns a structured error with hint for McpBrowserError', async () => {
const result = await buildErrorResponse(
new McpBrowserError('element not found', 'take a fresh snapshot'),
mockConnection.connection,
{},
{},
);
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: 'element not found',
hint: 'take a fresh snapshot',
});
});
it('wraps unknown errors', async () => {
const result = await buildErrorResponse(new Error('boom'), mockConnection.connection, {}, {});
expect(result.isError).toBe(true);
expect(structuredOf(result).error).toBe('boom');
});
it('includes a snapshot with autoSnapshot, threading the interactive flag', async () => {
mockConnection.adapter.snapshot.mockResolvedValue({ tree: '- button "OK"', refCount: 1 });
const result = await buildErrorResponse(
new Error('boom'),
mockConnection.connection,
{ pageId: 'page1' },
{ autoSnapshot: true, snapshotInteractive: true },
);
expect(mockConnection.adapter.snapshot).toHaveBeenCalledWith('page1', undefined, true);
expect(structuredOf(result).snapshot).toBe('- button "OK"');
});
it('redacts detected secrets from the error snapshot', async () => {
mockConnection.adapter.snapshot.mockResolvedValue({
tree: '- text "your key: sk-SECRET123"',
refCount: 0,
});
analyzeMock.mockReturnValue({
ok: true,
sensitive: true,
hits: [{ type: 'secret', value: 'sk-SECRET123' }],
});
const result = await buildErrorResponse(
new Error('boom'),
mockConnection.connection,
{},
{
autoSnapshot: true,
},
);
expect(structuredOf(result).snapshot).not.toContain('sk-SECRET123');
expect(JSON.stringify(result.content)).not.toContain('sk-SECRET123');
});
it('still returns the error when the snapshot fails', async () => {
mockConnection.adapter.snapshot.mockRejectedValue(new Error('page gone'));
const result = await buildErrorResponse(
new Error('boom'),
mockConnection.connection,
{},
{
autoSnapshot: true,
},
);
expect(result.isError).toBe(true);
expect(structuredOf(result)).toEqual({ error: 'boom' });
});
it('includes modal states', async () => {
const modal = {
type: 'filechooser' as const,
description: 'file chooser open',
clearedBy: 'browser_upload',
};
mockConnection.adapter.getModalStates.mockReturnValue([modal]);
const result = await buildErrorResponse(new Error('boom'), mockConnection.connection, {}, {});
expect(structuredOf(result).modalStates).toEqual([modal]);
});
it('still returns the error when the connection lookup fails', async () => {
const connection = {
getConnection: vi.fn().mockImplementation(() => {
throw new McpBrowserError('not connected');
}),
} as unknown as ReturnType<typeof createMockConnection>['connection'];
const result = await buildErrorResponse(
new Error('boom'),
connection,
{},
{
autoSnapshot: true,
},
);
expect(result.isError).toBe(true);
expect(structuredOf(result)).toEqual({ error: 'boom' });
});
});
@@ -26,6 +26,26 @@ export function resolvePageContext(
// Response enrichment (success path)
// ---------------------------------------------------------------------------
/**
* Attach a snapshot to the record and probe the page for sensitive values so
* the caller can redact them. Best-effort — failures leave the record as-is.
*/
async function attachSnapshot(
record: Record<string, unknown>,
state: ConnectionState,
pageId: string,
options: ConnectedToolOptions,
): Promise<SensitivityResult | undefined> {
try {
const snap = await state.adapter.snapshot(pageId, undefined, options.snapshotInteractive);
record.snapshot = snap.tree;
return analyzeHtmlSensitivity(await state.adapter.probePageHtml(pageId));
} catch {
// Snapshot failure shouldn't break the response
return undefined;
}
}
/**
* Inject snapshot, modal state, console summary, and new-tab diff into a
* structured response. All injections are best-effort — failures are silently
@@ -44,13 +64,7 @@ export async function enrichResponse(
let sensitivity: SensitivityResult | undefined;
if (options.autoSnapshot) {
try {
const snap = await state.adapter.snapshot(pageId);
record.snapshot = snap.tree;
sensitivity = analyzeHtmlSensitivity(await state.adapter.probePageHtml(pageId));
} catch {
// Snapshot failure shouldn't break the tool response
}
sensitivity = await attachSnapshot(record, state, pageId, options);
}
if (!options.autoSnapshot && shouldProbeResult(record)) {
@@ -132,18 +146,14 @@ export async function buildErrorResponse(
const errorData: Record<string, unknown> = { error: mcpError.message };
if (mcpError.hint) errorData.hint = mcpError.hint;
let sensitivity: SensitivityResult | undefined;
// Best-effort enrichment — connection itself may be broken
try {
const { state, pageId } = resolvePageContext(connection, args);
if (options.autoSnapshot) {
try {
const snap = await state.adapter.snapshot(pageId);
errorData.snapshot = snap.tree;
} catch {
// Snapshot failure on error path is expected
}
sensitivity = await attachSnapshot(errorData, state, pageId, options);
}
try {
@@ -156,9 +166,17 @@ export async function buildErrorResponse(
// Connection lookup failure — nothing more we can enrich
}
return {
const result: CallToolResult = {
content: [{ type: 'text' as const, text: JSON.stringify(errorData, null, 2) }],
structuredContent: errorData,
isError: true,
};
if (sensitivity?.ok) {
applyRedactions(result, sensitivity);
} else if (sensitivity && !sensitivity.ok) {
log.warn('sensitivity analysis failed during error enrichment', { error: sensitivity.error });
}
return result;
}
@@ -9,6 +9,13 @@ export const pageIdField = z
.optional()
.describe('Target page/tab ID. Defaults to active page');
export const snapshotField = z
.enum(['interactive', 'non-interactive'])
.optional()
.describe(
'Return a fresh page snapshot after the action. "interactive" annotates interactive elements with refs for follow-up actions; "non-interactive" returns the plain accessibility tree. Omit to skip.',
);
const refTargetSchema = z
.object({
ref: z.string().describe('Element ref from browser_snapshot (preferred)'),
@@ -63,7 +63,7 @@ describe('createTabTools', () => {
const data = structuredOf(result);
expect(data.snapshot).toBe('<snapshot-tree>');
expect(mockConnection.adapter.snapshot).toHaveBeenCalledWith('page2');
expect(mockConnection.adapter.snapshot).toHaveBeenCalledWith('page2', undefined, undefined);
});
});
});