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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 443 additions and 526 deletions

View File

@ -19,6 +19,7 @@
"build": "tsc",
"build:extension": "tsc --project extension",
"lint": "npm run update-readme && eslint . && tsc --noEmit",
"lint-fix": "eslint . --fix",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"watch:extension": "tsc --watch --project extension",

View File

@ -19,6 +19,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context.js';
import { Response } from './response.js';
import { allTools } from './tools.js';
import { packageJSON } from './package.js';
@ -61,7 +62,9 @@ export function createConnection(config: FullConfig, browserContextFactory: Brow
return errorResult(`Tool "${request.params.name}" not found`);
try {
return await context.run(tool, request.params.arguments);
const response = new Response(context);
await tool.handle(context, tool.schema.inputSchema.parse(request.params.arguments || {}), response);
return await response.serialize();
} catch (error) {
return errorResult(String(error));
}

View File

@ -59,8 +59,12 @@ export class Context {
}
async selectTab(index: number) {
this._currentTab = this._tabs[index];
await this._currentTab.page.bringToFront();
const tab = this._tabs[index];
if (!tab)
throw new Error(`Tab ${index} not found`);
await tab.page.bringToFront();
this._currentTab = tab;
return tab;
}
async ensureTab(): Promise<Tab> {
@ -70,9 +74,18 @@ export class Context {
return this._currentTab!;
}
async listTabsMarkdown(): Promise<string[]> {
if (!this._tabs.length)
return ['### No tabs open'];
async listTabsMarkdown(force: boolean = false): Promise<string[]> {
if (this._tabs.length === 1 && !force)
return [];
if (!this._tabs.length) {
return [
'### No open tabs',
'Use the "browser_navigate" tool to navigate to a page first.',
'',
];
}
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i];
@ -81,62 +94,17 @@ export class Context {
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i}:${current} [${title}] (${url})`);
}
lines.push('');
return lines;
}
async closeTab(index: number | undefined) {
async closeTab(index: number | undefined): Promise<string> {
const tab = index === undefined ? this._currentTab : this._tabs[index];
await tab?.page.close();
return await this.listTabsMarkdown();
}
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
if (resultOverride)
return resultOverride;
const tab = this.currentTabOrDie();
const { actionResult, snapshot } = await tab.run(action || (() => Promise.resolve()), { waitForNetwork, captureSnapshot });
const result: string[] = [];
result.push(`### Ran Playwright code
\`\`\`js
${code.join('\n')}
\`\`\``);
if (tab.modalStates().length) {
result.push('', ...tab.modalStatesMarkdown());
return {
content: [{
type: 'text',
text: result.join('\n'),
}],
};
}
result.push(...tab.takeRecentConsoleMarkdown());
result.push(...tab.listDownloadsMarkdown());
if (snapshot) {
if (this.tabs().length > 1)
result.push('', ...(await this.listTabsMarkdown()));
result.push('', snapshot);
}
const content = actionResult?.content ?? [];
return {
content: [
...content,
{
type: 'text',
text: result.join('\n'),
}
],
};
if (!tab)
throw new Error(`Tab ${index} not found`);
const url = tab.page.url();
await tab.page.close();
return url;
}
private _onPageCreated(page: playwright.Page) {

101
src/response.ts Normal file
View File

@ -0,0 +1,101 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Context } from './context.js';
export class Response {
private _result: string[] = [];
private _code: string[] = [];
private _images: { contentType: string, data: Buffer }[] = [];
private _context: Context;
private _includeSnapshot = false;
private _snapshot: string | undefined;
private _includeTabs = false;
constructor(context: Context) {
this._context = context;
}
addResult(result: string) {
this._result.push(result);
}
addCode(code: string) {
this._code.push(code);
}
addImage(image: { contentType: string, data: Buffer }) {
this._images.push(image);
}
setIncludeSnapshot() {
this._includeSnapshot = true;
}
setIncludeTabs() {
this._includeTabs = true;
}
includeSnapshot() {
return this._includeSnapshot;
}
addSnapshot(snapshot: string) {
this._snapshot = snapshot;
}
async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> {
const response: string[] = [];
// Start with command result.
if (this._result.length) {
response.push('### Result');
response.push(this._result.join('\n'));
response.push('');
}
// Add code if it exists.
if (this._code.length) {
response.push(`### Ran Playwright code
\`\`\`js
${this._code.join('\n')}
\`\`\``);
response.push('');
}
// List browser tabs.
if (this._includeSnapshot || this._includeTabs)
response.push(...(await this._context.listTabsMarkdown(this._includeTabs)));
// Add snapshot if provided.
if (this._snapshot)
response.push(this._snapshot, '');
// Main response part
const content: (TextContent | ImageContent)[] = [
{ type: 'text', text: response.join('\n') },
];
// Image attachments.
if (this._context.config.imageResponses !== 'omit') {
for (const image of this._images)
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
}
return { content };
}
}

View File

@ -23,7 +23,7 @@ import { ModalState } from './tools/tool.js';
import { outputFile } from './config.js';
import type { Context } from './context.js';
import type { ToolActionResult } from './tools/tool.js';
import type { Response } from './response.js';
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
@ -85,7 +85,7 @@ export class Tab {
if (this._modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of this._modalStates) {
const tool = this.context.tools.find(tool => tool.clearsModalState === state.type);
const tool = this.context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
@ -151,10 +151,13 @@ export class Tab {
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
const download = await Promise.race([
downloadEvent,
new Promise(resolve => setTimeout(resolve, 1000)),
new Promise(resolve => setTimeout(resolve, 3000)),
]);
if (!download)
throw e;
// Make sure other "download" listeners are notified first.
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
// Cap load event to 5 seconds, the page is operational at this point.
@ -169,40 +172,53 @@ export class Tab {
return this._requests;
}
takeRecentConsoleMarkdown(): string[] {
private _takeRecentConsoleMarkdown(): string[] {
if (!this._recentConsoleMessages.length)
return [];
const result = this._recentConsoleMessages.map(message => {
return `- ${trim(message.toString(), 100)}`;
});
return ['', `### New console messages`, ...result];
return [`### New console messages`, ...result, ''];
}
listDownloadsMarkdown(): string[] {
private _listDownloadsMarkdown(): string[] {
if (!this._downloads.length)
return [];
const result: string[] = ['', '### Downloads'];
const result: string[] = ['### Downloads'];
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
result.push('');
return result;
}
async captureSnapshot(): Promise<string> {
async captureSnapshot(options: { omitAriaSnapshot?: boolean } = {}): Promise<string> {
const result: string[] = [];
if (this.modalStates().length) {
result.push(...this.modalStatesMarkdown());
return result.join('\n');
}
result.push(...this._takeRecentConsoleMarkdown());
result.push(...this._listDownloadsMarkdown());
if (options.omitAriaSnapshot)
return result.join('\n');
const snapshot = await (this.page as PageEx)._snapshotForAI();
return [
`### Page state`,
`- Page URL: ${this.page.url()}`,
`- Page Title: ${await this.page.title()}`,
`- Page Snapshot:`,
'```yaml',
snapshot,
'```',
].join('\n');
result.push(
`### Page state`,
`- Page URL: ${this.page.url()}`,
`- Page Title: ${await this.page.title()}`,
`- Page Snapshot:`,
'```yaml',
snapshot,
'```',
);
return result.join('\n');
}
private _javaScriptBlocked(): boolean {
@ -226,20 +242,25 @@ export class Tab {
return result;
}
async run(callback: () => Promise<ToolActionResult>, options: { waitForNetwork?: boolean, captureSnapshot?: boolean }): Promise<{ actionResult: ToolActionResult | undefined, snapshot: string | undefined }> {
async run(callback: () => Promise<void>, response: Response) {
let snapshot: string | undefined;
const actionResult = await this._raceAgainstModalDialogs(async () => {
await this._raceAgainstModalDialogs(async () => {
try {
if (options.waitForNetwork)
return await waitForCompletion(this, async () => callback?.()) ?? undefined;
if (response.includeSnapshot())
await waitForCompletion(this, callback);
else
return await callback?.() ?? undefined;
await callback();
} finally {
if (options.captureSnapshot && !this._javaScriptBlocked())
snapshot = await this.captureSnapshot();
snapshot = await this.captureSnapshot();
}
});
return { actionResult, snapshot };
if (snapshot) {
response.addSnapshot(snapshot);
} else if (response.includeSnapshot()) {
// We are blocked on modal dialog.
response.addSnapshot(await this.captureSnapshot({ omitAriaSnapshot: true }));
}
}
async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {
@ -247,7 +268,7 @@ export class Tab {
}
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
const snapshot = await this.captureSnapshot();
const snapshot = await (this.page as PageEx)._snapshotForAI();
return params.map(param => {
if (!snapshot.includes(`[ref=${param.ref}]`))
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);

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());
},
});

View File

@ -45,13 +45,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
expect(await client.callTool({
name: 'browser_snapshot',
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// <internal code to capture accessibility snapshot>
\`\`\`
### Page state
})).toHaveTextContent(`### Page state
- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot:

View File

@ -38,6 +38,7 @@ test('browser_console_messages', async ({ client, server }) => {
name: 'browser_console_messages',
});
expect(resource).toHaveTextContent([
'### Result',
`[LOG] Hello, world! @ ${server.PREFIX}:4`,
`[ERROR] Error @ ${server.PREFIX}:5`,
].join('\n'));

View File

@ -36,7 +36,8 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@ -46,17 +47,7 @@ await page.getByRole('button', { name: 'Button' }).click();
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`### Ran Playwright code
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:
\`\`\`yaml
- button "Button"`);
expect(result).toContainTextContent(`Page Snapshot:`);
});
test('two alert dialogs', async ({ client, server }) => {
@ -85,7 +76,8 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`);
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@ -94,9 +86,9 @@ await page.getByRole('button', { name: 'Button' }).click();
},
});
expect(result).toContainTextContent(`
### Modal state
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`);
expect(result).toContainTextContent(`### Modal state
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool
`);
const result2 = await client.callTool({
name: 'browser_handle_dialog',
@ -138,7 +130,6 @@ test('confirm dialog (true)', async ({ client, server }) => {
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: "true"
@ -165,7 +156,8 @@ test('confirm dialog (false)', async ({ client, server }) => {
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@ -200,7 +192,8 @@ test('prompt dialog', async ({ client, server }) => {
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@ -236,7 +229,8 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@ -246,12 +240,7 @@ await page.getByRole('button', { name: 'Button' }).click();
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`### Ran Playwright code
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
### Page state
expect(result).toContainTextContent(`### Page state
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:

View File

@ -47,7 +47,8 @@ test('browser_evaluate (element)', async ({ client, server }) => {
element: 'body',
ref: 'e1',
},
})).toContainTextContent(`- Result: "red"`);
})).toContainTextContent(`### Result
"red"`);
});
test('browser_evaluate (error)', async ({ client, server }) => {

View File

@ -113,8 +113,7 @@ test('clicking on download link emits download', async ({ startClient, server, m
ref: 'e2',
},
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
### Downloads
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`### Downloads
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
});
@ -123,7 +122,7 @@ test('navigating to download link emits download', async ({ startClient, server,
config: { outputDir: testInfo.outputPath('output') },
});
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
test.skip(mcpBrowser !== 'chromium', 'This test is racy');
server.route('/download', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',

View File

@ -20,5 +20,5 @@ test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({
name: 'browser_install',
})).toContainTextContent(`No open pages available.`);
})).toContainTextContent(`### No open tabs`);
});

View File

@ -27,7 +27,13 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
expect(await client.callTool({
name: 'browser_close',
})).toContainTextContent('No open pages available');
})).toContainTextContent(`### Ran Playwright code
\`\`\`js
await page.close()
\`\`\`
### No open tabs
Use the "browser_navigate" tool to navigate to a page first.`);
expect(await client.callTool({
name: 'browser_navigate',

View File

@ -40,6 +40,7 @@ test('browser_network_requests', async ({ client, server }) => {
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
})).toHaveTextContent(`### Result
[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`);
});

View File

@ -65,14 +65,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
arguments: {
filename: 'output.pdf',
},
})).toEqual({
content: [
{
type: 'text',
text: expect.stringContaining(`output.pdf`),
},
],
});
})).toContainTextContent(`output.pdf`);
const files = [...fs.readdirSync(outputDir)];

View File

@ -31,15 +31,15 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});
@ -61,15 +61,15 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
},
})).toEqual({
content: [
{
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
],
});
});
@ -111,17 +111,17 @@ for (const raw of [undefined, true]) {
arguments: { raw },
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
{
text: expect.stringMatching(
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
),
type: 'text',
},
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
],
});
@ -153,15 +153,15 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
},
})).toEqual({
content: [
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
],
});
@ -216,15 +216,15 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
arguments: { fullPage: true },
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot full page and save it as`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`Screenshot full page and save it as`),
type: 'text',
},
],
});
});
@ -266,15 +266,15 @@ test('browser_take_screenshot (viewport without snapshot)', async ({ startClient
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});

View File

@ -44,11 +44,13 @@ test('list first tab', async ({ client }) => {
});
test('create new tab', async ({ client }) => {
expect(await createTab(client, 'Tab one', 'Body one')).toContainTextContent(`
### Open tabs
const result = await createTab(client, 'Tab one', 'Body one');
expect(result).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
`);
expect(result).toContainTextContent(`
### Page state
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
@ -57,12 +59,14 @@ test('create new tab', async ({ client }) => {
- generic [active] [ref=e1]: Body one
\`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toContainTextContent(`
### Open tabs
const result2 = await createTab(client, 'Tab two', 'Body two');
expect(result2).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
`);
expect(result2).toContainTextContent(`
### Page state
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two
@ -82,8 +86,7 @@ test('select tab', async ({ client }) => {
index: 1,
},
});
expect(result).toContainTextContent(`
### Open tabs
expect(result).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`);
@ -108,8 +111,7 @@ test('close tab', async ({ client }) => {
index: 2,
},
});
expect(result).toContainTextContent(`
### Open tabs
expect(result).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);