mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 09:51:59 +08:00
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:
co-authored by
Claude Fable 5
parent
01ce4490d0
commit
7f3713702a
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user