chore: introduce response type (#738)

This commit is contained in:
Pavel Feldman
2025-07-22 16:36:21 -07:00
committed by GitHub
parent c2b98dc70b
commit 601a74305c
32 changed files with 443 additions and 526 deletions

View File

@@ -28,13 +28,10 @@ const close = defineTool({
type: 'readOnly',
},
handle: async context => {
handle: async (context, params, response) => {
await context.close();
return {
code: [`await page.close()`],
captureSnapshot: false,
waitForNetwork: false,
};
response.setIncludeTabs();
response.addCode(`await page.close()`);
},
});
@@ -51,22 +48,13 @@ const resize = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params) => {
const code = [
`// Resize browser window to ${params.width}x${params.height}`,
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
];
handle: async (tab, params, response) => {
response.addCode(`// Resize browser window to ${params.width}x${params.height}`);
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
const action = async () => {
await tab.run(async () => {
await tab.page.setViewportSize({ width: params.width, height: params.height });
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true
};
}, response);
},
});

View File

@@ -26,19 +26,8 @@ const console = defineTabTool({
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async tab => {
const messages = tab.consoleMessages();
const log = messages.map(message => message.toString()).join('\n');
return {
code: [`// <internal code to get console messages>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
handle: async (tab, params, response) => {
tab.consoleMessages().map(message => response.addResult(message.toString()));
},
});

View File

@@ -31,27 +31,20 @@ const handleDialog = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const dialogState = tab.modalStates().find(state => state.type === 'dialog');
if (!dialogState)
throw new Error('No dialog visible');
if (params.accept)
await dialogState.dialog.accept(params.promptText);
else
await dialogState.dialog.dismiss();
tab.clearModalState(dialogState);
const code = [
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
await tab.run(async () => {
if (params.accept)
await dialogState.dialog.accept(params.promptText);
else
await dialogState.dialog.dismiss();
}, response);
},
clearsModalState: 'dialog',

View File

@@ -38,29 +38,22 @@ const evaluate = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
const code: string[] = [];
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
let locator: playwright.Locator | undefined;
if (params.ref && params.element) {
locator = await tab.refLocator({ ref: params.ref, element: params.element });
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
response.addCode(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
} else {
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
}
return {
code,
action: async () => {
const receiver = locator ?? tab.page as any;
const result = await receiver._evaluateFunction(params.function);
return {
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
};
},
captureSnapshot: false,
waitForNetwork: false,
};
await tab.run(async () => {
const receiver = locator ?? tab.page as any;
const result = await receiver._evaluateFunction(params.function);
response.addResult(JSON.stringify(result, null, 2) || 'undefined');
}, response);
},
});

View File

@@ -30,26 +30,20 @@ const uploadFile = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
if (!modalState)
throw new Error('No file chooser visible');
const code = [
`// <internal code to chose files ${params.paths.join(', ')}`,
];
response.addCode(`// Select files for upload`);
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
const action = async () => {
await tab.run(async () => {
await modalState.fileChooser.setFiles(params.paths);
tab.clearModalState(modalState);
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true,
};
}, response);
},
clearsModalState: 'fileChooser',
});

View File

@@ -31,7 +31,7 @@ const install = defineTool({
type: 'destructive',
},
handle: async context => {
handle: async (context, params, response) => {
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
const cliUrl = import.meta.resolve('playwright/package.json');
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
@@ -49,11 +49,7 @@ const install = defineTool({
reject(new Error(`Failed to install browser: ${output.join('')}`));
});
});
return {
code: [`// Browser ${channel} installed`],
captureSnapshot: false,
waitForNetwork: false,
};
response.setIncludeTabs();
},
});

View File

@@ -34,20 +34,14 @@ const pressKey = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
const code = [
`// Press ${params.key}`,
`await page.keyboard.press('${params.key}');`,
];
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
response.addCode(`// Press ${params.key}`);
response.addCode(`await page.keyboard.press('${params.key}');`);
const action = () => tab.page.keyboard.press(params.key);
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true
};
await tab.run(async () => {
await tab.page.keyboard.press(params.key);
}, response);
},
});
@@ -67,34 +61,27 @@ const type = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
await tab.run(async () => {
if (params.slowly) {
response.addCode(`// Press "${params.text}" sequentially into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
await locator.pressSequentially(params.text);
} else {
response.addCode(`// Fill "${params.text}" into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
await locator.fill(params.text);
}
if (params.slowly) {
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
steps.push(() => locator.pressSequentially(params.text));
} else {
code.push(`// Fill "${params.text}" into "${params.element}"`);
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
steps.push(() => locator.fill(params.text));
}
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
steps.push(() => locator.press('Enter'));
}
return {
code,
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
captureSnapshot: true,
waitForNetwork: true,
};
if (params.submit) {
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
await locator.press('Enter');
}
}, response);
},
});

View File

@@ -34,18 +34,13 @@ const mouseMove = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params) => {
const code = [
`// Move mouse to (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`,
];
const action = () => tab.page.mouse.move(params.x, params.y);
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false
};
handle: async (tab, params, response) => {
response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
await tab.run(async () => {
await tab.page.mouse.move(params.x, params.y);
}, response);
},
});
@@ -62,24 +57,19 @@ const mouseClick = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
const code = [
`// Click mouse at coordinates (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`,
`await page.mouse.down();`,
`await page.mouse.up();`,
];
const action = async () => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`);
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
response.addCode(`await page.mouse.down();`);
response.addCode(`await page.mouse.up();`);
await tab.run(async () => {
await tab.page.mouse.move(params.x, params.y);
await tab.page.mouse.down();
await tab.page.mouse.up();
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
}, response);
},
});
@@ -98,28 +88,21 @@ const mouseDrag = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
const code = [
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
`await page.mouse.move(${params.startX}, ${params.startY});`,
`await page.mouse.down();`,
`await page.mouse.move(${params.endX}, ${params.endY});`,
`await page.mouse.up();`,
];
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const action = async () => {
response.addCode(`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`);
response.addCode(`await page.mouse.move(${params.startX}, ${params.startY});`);
response.addCode(`await page.mouse.down();`);
response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`);
response.addCode(`await page.mouse.up();`);
await tab.run(async () => {
await tab.page.mouse.move(params.startX, params.startY);
await tab.page.mouse.down();
await tab.page.mouse.move(params.endX, params.endY);
await tab.page.mouse.up();
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
}, response);
},
});

View File

@@ -30,20 +30,13 @@ const navigate = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
handle: async (context, params, response) => {
const tab = await context.ensureTab();
await tab.navigate(params.url);
const code = [
`// Navigate to ${params.url}`,
`await page.goto('${params.url}');`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
response.addCode(`// Navigate to ${params.url}`);
response.addCode(`await page.goto('${params.url}');`);
response.addSnapshot(await tab.captureSnapshot());
},
});
@@ -57,18 +50,13 @@ const goBack = defineTabTool({
type: 'readOnly',
},
handle: async tab => {
await tab.page.goBack();
const code = [
`// Navigate back`,
`await page.goBack();`,
];
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
await tab.page.goBack();
response.addCode(`// Navigate back`);
response.addCode(`await page.goBack();`);
response.addSnapshot(await tab.captureSnapshot());
},
});
@@ -81,17 +69,13 @@ const goForward = defineTabTool({
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async tab => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
await tab.page.goForward();
const code = [
`// Navigate forward`,
`await page.goForward();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
response.addCode(`// Navigate forward`);
response.addCode(`await page.goForward();`);
response.addSnapshot(await tab.captureSnapshot());
},
});

View File

@@ -30,19 +30,9 @@ const requests = defineTabTool({
type: 'readOnly',
},
handle: async tab => {
handle: async (tab, params, response) => {
const requests = tab.requests();
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
return {
code: [`// <internal code to list network requests>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
},
});

View File

@@ -35,20 +35,12 @@ const pdf = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
const code = [
`// Save page as ${fileName}`,
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
];
return {
code,
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
captureSnapshot: false,
waitForNetwork: false,
};
response.addCode(`// Save page as ${fileName}`);
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
response.addResult(`Saved page as ${fileName}`);
await tab.page.pdf({ path: fileName });
},
});

View File

@@ -51,7 +51,7 @@ const screenshot = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = {
@@ -64,36 +64,22 @@ const screenshot = defineTabTool({
const isElementScreenshot = params.element && params.ref;
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
const code = [
`// Screenshot ${screenshotTarget} and save it as ${fileName}`,
];
response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
// Only get snapshot when element screenshot is needed
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
if (locator)
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
response.addCode(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
else
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
const includeBase64 = tab.context.config.imageResponses !== 'omit';
const action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return {
content: includeBase64 ? [{
type: 'image' as 'image',
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}] : []
};
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false,
};
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
response.addImage({
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
data: buffer
});
}
});

View File

@@ -30,14 +30,9 @@ const snapshot = defineTool({
type: 'readOnly',
},
handle: async context => {
await context.ensureTab();
return {
code: [`// <internal code to capture accessibility snapshot>`],
captureSnapshot: true,
waitForNetwork: false,
};
handle: async (context, params, response) => {
const tab = await context.ensureTab();
response.addSnapshot(await tab.captureSnapshot());
},
});
@@ -61,26 +56,27 @@ const click = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params);
const button = params.button;
const buttonAttr = button ? `{ button: '${button}' }` : '';
const code: string[] = [];
if (params.doubleClick) {
code.push(`// Double click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
response.addCode(`// Double click ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
} else {
code.push(`// Click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
response.addCode(`// Click ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
}
return {
code,
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }),
captureSnapshot: true,
waitForNetwork: true,
};
await tab.run(async () => {
if (params.doubleClick)
await locator.dblclick({ button });
else
await locator.click({ button });
}, response);
},
});
@@ -99,23 +95,19 @@ const drag = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const [startLocator, endLocator] = await tab.refLocators([
{ ref: params.startRef, element: params.startElement },
{ ref: params.endRef, element: params.endElement },
]);
const code = [
`// Drag ${params.startElement} to ${params.endElement}`,
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
];
await tab.run(async () => {
await startLocator.dragTo(endLocator);
}, response);
return {
code,
action: () => startLocator.dragTo(endLocator),
captureSnapshot: true,
waitForNetwork: true,
};
response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
},
});
@@ -129,20 +121,15 @@ const hover = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params);
response.addCode(`await page.${await generateLocator(locator)}.hover();`);
const code = [
`// Hover over ${params.element}`,
`await page.${await generateLocator(locator)}.hover();`
];
return {
code,
action: () => locator.hover(),
captureSnapshot: true,
waitForNetwork: true,
};
await tab.run(async () => {
await locator.hover();
}, response);
},
});
@@ -160,20 +147,16 @@ const selectOption = defineTabTool({
type: 'destructive',
},
handle: async (tab, params) => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params);
response.addCode(`// Select options [${params.values.join(', ')}] in ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
const code = [
`// Select options [${params.values.join(', ')}] in ${params.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
];
return {
code,
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
await tab.run(async () => {
await locator.selectOption(params.values);
}, response);
},
});

View File

@@ -28,19 +28,9 @@ const listTabs = defineTool({
type: 'readOnly',
},
handle: async context => {
handle: async (context, params, response) => {
await context.ensureTab();
return {
code: [`// <internal code to list tabs>`],
captureSnapshot: false,
waitForNetwork: false,
resultOverride: {
content: [{
type: 'text',
text: (await context.listTabsMarkdown()).join('\n'),
}],
},
};
response.setIncludeTabs();
},
});
@@ -57,17 +47,10 @@ const selectTab = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
await context.selectTab(params.index);
const code = [
`// <internal code to select tab ${params.index}>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
handle: async (context, params, response) => {
const tab = await context.selectTab(params.index);
response.setIncludeSnapshot();
response.addSnapshot(await tab.captureSnapshot());
},
});
@@ -84,19 +67,13 @@ const newTab = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
handle: async (context, params, response) => {
const tab = await context.newTab();
if (params.url)
await tab.navigate(params.url);
const code = [
`// <internal code to open a new tab>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
response.setIncludeSnapshot();
response.addSnapshot(await tab.captureSnapshot());
},
});
@@ -113,16 +90,12 @@ const closeTab = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
handle: async (context, params, response) => {
await context.closeTab(params.index);
const code = [
`// <internal code to close tab ${params.index}>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
response.setIncludeTabs();
response.addCode(`await myPage.close();`);
if (context.tabs().length)
response.addSnapshot(await context.currentTabOrDie().captureSnapshot());
},
});

View File

@@ -14,12 +14,12 @@
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { z } from 'zod';
import type { Context } from '../context.js';
import type * as playwright from 'playwright';
import type { ToolCapability } from '../../config.js';
import type { Tab } from '../tab.js';
import type { Response } from '../response.js';
export type ToolSchema<Input extends InputType> = {
name: string;
@@ -45,21 +45,30 @@ export type DialogModalState = {
export type ModalState = FileUploadModalState | DialogModalState;
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
export type SnapshotContent = {
type: 'snapshot';
snapshot: string;
};
export type ToolResult = {
export type TextContent = {
type: 'text';
text: string;
};
export type ImageContent = {
type: 'image';
image: string;
};
export type CodeContent = {
type: 'code';
code: string[];
action?: () => Promise<ToolActionResult>;
captureSnapshot: boolean;
waitForNetwork: boolean;
resultOverride?: ToolActionResult;
};
export type Tool<Input extends InputType = InputType> = {
capability: ToolCapability;
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
handle: (context: Context, params: z.output<Input>, response: Response) => Promise<void>;
};
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
@@ -70,20 +79,20 @@ export type TabTool<Input extends InputType = InputType> = {
capability: ToolCapability;
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (tab: Tab, params: z.output<Input>) => Promise<ToolResult>;
handle: (tab: Tab, params: z.output<Input>, response: Response) => Promise<void>;
};
export function defineTabTool<Input extends InputType>(tool: TabTool<Input>): Tool<Input> {
return {
...tool,
handle: async (context, params) => {
handle: async (context, params, response) => {
const tab = context.currentTabOrDie();
const modalStates = tab.modalStates().map(state => state.type);
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
throw new Error(`The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
if (!tool.clearsModalState && modalStates.length)
throw new Error(`Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
return tool.handle(tab, params);
return tool.handle(tab, params, response);
},
};
}

View File

@@ -32,7 +32,7 @@ const wait = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
handle: async (context, params, response) => {
if (!params.text && !params.textGone && !params.time)
throw new Error('Either time, text or textGone must be provided');
@@ -57,11 +57,8 @@ const wait = defineTool({
await locator.waitFor({ state: 'visible' });
}
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
response.addSnapshot(await tab.captureSnapshot());
},
});