4 Commits

Author SHA1 Message Date
Pavel Feldman
586492a3f0 chore: mark v0.0.15 (#250) 2025-04-22 16:17:36 -07:00
Pavel Feldman
f7e9bae571 chore: roll playwright to 1745357020000 (#249) 2025-04-22 16:04:50 -07:00
Pavel Feldman
1bc3c761de feat(network): implement listing network requests (#247)
Fixes: https://github.com/microsoft/playwright-mcp/issues/242
2025-04-22 16:04:25 -07:00
Simon Knott
c80f7cf222 chore: infer tool params (#241)
Moves the `schema.parse` call to the calling side of the handler, so we
don't have to duplicate it everywhere.
2025-04-22 13:24:38 +02:00
33 changed files with 656 additions and 308 deletions

View File

@@ -58,4 +58,4 @@ jobs:
run: npx playwright install --with-deps
- name: Run tests
run: npm test
run: npm test -- --forbid-only

30
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "@playwright/mcp",
"version": "0.0.14",
"version": "0.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.14",
"version": "0.0.15",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"commander": "^13.1.0",
"playwright": "^1.52.0-alpha-1743163434000",
"playwright": "1.53.0-alpha-1745357020000",
"yaml": "^2.7.1",
"zod-to-json-schema": "^3.24.4"
},
@@ -21,7 +21,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "^1.52.0-alpha-1743163434000",
"@playwright/test": "1.53.0-alpha-1745357020000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
@@ -286,13 +286,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
"version": "1.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-7xQRHhsS//elVJVt2WybJPXAy++WiE8yJzMtVFcnzdQNg9VNSbpqo4b61io5IIG1nEfB22N4BhjQ/8jPrUyu9A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.52.0-alpha-1743163434000"
"playwright": "1.53.0-alpha-1745357020000"
},
"bin": {
"playwright": "cli.js"
@@ -3301,12 +3301,12 @@
}
},
"node_modules/playwright": {
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
"version": "1.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-evnZJIB1CRSA1HfwCkLhyqyGZybSWdNwfyyUWhBoez9ISbYMuYrTtidx75oiGVXtbKr5s8iC0+opuvagu4L1vA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0-alpha-1743163434000"
"playwright-core": "1.53.0-alpha-1745357020000"
},
"bin": {
"playwright": "cli.js"
@@ -3319,9 +3319,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
"version": "1.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-3oPzOUwJ/yhNWUs3fh5UbmI1Mf18sHUDo3gxzuPwqxN3QCSFKx9Ncg7cSB+FyJCkgz7ZD8fUlzJ75YsDE+PMfA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.14",
"version": "0.0.15",
"description": "Playwright Tools for MCP",
"repository": {
"type": "git",
@@ -36,14 +36,14 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"commander": "^13.1.0",
"playwright": "^1.52.0-alpha-1743163434000",
"playwright": "1.53.0-alpha-1745357020000",
"yaml": "^2.7.1",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "^1.52.0-alpha-1743163434000",
"@playwright/test": "1.53.0-alpha-1745357020000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",

View File

@@ -124,7 +124,7 @@ export class Context {
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, params);
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
@@ -317,6 +317,7 @@ export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
@@ -325,9 +326,11 @@ export class Tab {
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._console.push(event));
page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._console.length = 0;
this._clearCollectedArtifacts();
});
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
@@ -342,8 +345,13 @@ export class Tab {
page.setDefaultTimeout(5000);
}
private _onClose() {
private _clearCollectedArtifacts() {
this._console.length = 0;
this._requests.clear();
}
private _onClose() {
this._clearCollectedArtifacts();
this._onPageClose(this);
}
@@ -363,10 +371,14 @@ export class Tab {
return this._snapshot;
}
async console(): Promise<playwright.ConsoleMessage[]> {
console(): playwright.ConsoleMessage[] {
return this._console;
}
requests(): Map<playwright.Request, playwright.Response | null> {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
@@ -401,7 +413,7 @@ class PageSnapshot {
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
const snapshot = yaml.parseDocument(snapshotString);
const visit = async (node: any): Promise<unknown> => {

View File

@@ -26,6 +26,7 @@ import files from './tools/files';
import install from './tools/install';
import keyboard from './tools/keyboard';
import navigate from './tools/navigate';
import network from './tools/network';
import pdf from './tools/pdf';
import snapshot from './tools/snapshot';
import tabs from './tools/tabs';
@@ -35,7 +36,7 @@ import type { Tool, ToolCapability } from './tools/tool';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { LaunchOptions } from 'playwright';
const snapshotTools: Tool[] = [
const snapshotTools: Tool<any>[] = [
...common(true),
...console,
...dialogs(true),
@@ -43,12 +44,13 @@ const snapshotTools: Tool[] = [
...install,
...keyboard(true),
...navigate(true),
...network,
...pdf,
...snapshot,
...tabs(true),
];
const screenshotTools: Tool[] = [
const screenshotTools: Tool<any>[] = [
...common(false),
...console,
...dialogs(false),
@@ -56,6 +58,7 @@ const screenshotTools: Tool[] = [
...install,
...keyboard(false),
...navigate(false),
...network,
...pdf,
...screen,
...tabs(false),

View File

@@ -16,6 +16,7 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context';
@@ -41,7 +42,13 @@ export function createServerWithTools(options: Options): Server {
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: tools.map(tool => tool.schema) };
return {
tools: tools.map(tool => ({
name: tool.schema.name,
description: tool.schema.description,
inputSchema: zodToJsonSchema(tool.schema.inputSchema)
})),
};
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {

View File

@@ -15,43 +15,36 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { Tool, ToolFactory } from './tool';
const waitSchema = z.object({
time: z.number().describe('The time to wait in seconds'),
});
const wait: ToolFactory = captureSnapshot => ({
const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',
schema: {
name: 'browser_wait',
description: 'Wait for a specified time in seconds',
inputSchema: zodToJsonSchema(waitSchema),
inputSchema: z.object({
time: z.number().describe('The time to wait in seconds'),
}),
},
handle: async (context, params) => {
const validatedParams = waitSchema.parse(params);
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
return {
code: [`// Waited for ${validatedParams.time} seconds`],
code: [`// Waited for ${params.time} seconds`],
captureSnapshot,
waitForNetwork: false,
};
},
});
const closeSchema = z.object({});
const close: Tool = {
const close = defineTool({
capability: 'core',
schema: {
name: 'browser_close',
description: 'Close the page',
inputSchema: zodToJsonSchema(closeSchema),
inputSchema: z.object({}),
},
handle: async context => {
@@ -62,33 +55,29 @@ const close: Tool = {
waitForNetwork: false,
};
},
};
const resizeSchema = z.object({
width: z.number().describe('Width of the browser window'),
height: z.number().describe('Height of the browser window'),
});
const resize: ToolFactory = captureSnapshot => ({
const resize: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_resize',
description: 'Resize the browser window',
inputSchema: zodToJsonSchema(resizeSchema),
inputSchema: z.object({
width: z.number().describe('Width of the browser window'),
height: z.number().describe('Height of the browser window'),
}),
},
handle: async (context, params) => {
const validatedParams = resizeSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Resize browser window to ${validatedParams.width}x${validatedParams.height}`,
`await page.setViewportSize({ width: ${validatedParams.width}, height: ${validatedParams.height} });`
`// Resize browser window to ${params.width}x${params.height}`,
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
];
const action = async () => {
await tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height });
await tab.page.setViewportSize({ width: params.width, height: params.height });
};
return {

View File

@@ -15,21 +15,17 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool';
import type { Tool } from './tool';
const consoleSchema = z.object({});
const console: Tool = {
const console = defineTool({
capability: 'core',
schema: {
name: 'browser_console_messages',
description: 'Returns all console messages',
inputSchema: zodToJsonSchema(consoleSchema),
inputSchema: z.object({}),
},
handle: async context => {
const messages = await context.currentTabOrDie().console();
const messages = context.currentTabOrDie().console();
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
return {
code: [`// <internal code to get console messages>`],
@@ -42,7 +38,7 @@ const console: Tool = {
waitForNetwork: false,
};
},
};
});
export default [
console,

View File

@@ -15,32 +15,27 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory } from './tool';
const handleDialogSchema = z.object({
accept: z.boolean().describe('Whether to accept the dialog.'),
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
});
const handleDialog: ToolFactory = captureSnapshot => ({
const handleDialog: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_handle_dialog',
description: 'Handle a dialog',
inputSchema: zodToJsonSchema(handleDialogSchema),
inputSchema: z.object({
accept: z.boolean().describe('Whether to accept the dialog.'),
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
}),
},
handle: async (context, params) => {
const validatedParams = handleDialogSchema.parse(params);
const dialogState = context.modalStates().find(state => state.type === 'dialog');
if (!dialogState)
throw new Error('No dialog visible');
if (validatedParams.accept)
await dialogState.dialog.accept(validatedParams.promptText);
if (params.accept)
await dialogState.dialog.accept(params.promptText);
else
await dialogState.dialog.dismiss();

View File

@@ -15,35 +15,30 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory } from './tool';
const uploadFileSchema = z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
});
const uploadFile: ToolFactory = captureSnapshot => ({
const uploadFile: ToolFactory = captureSnapshot => defineTool({
capability: 'files',
schema: {
name: 'browser_file_upload',
description: 'Upload one or multiple files',
inputSchema: zodToJsonSchema(uploadFileSchema),
inputSchema: z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
}),
},
handle: async (context, params) => {
const validatedParams = uploadFileSchema.parse(params);
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
if (!modalState)
throw new Error('No file chooser visible');
const code = [
`// <internal code to chose files ${validatedParams.paths.join(', ')}`,
`// <internal code to chose files ${params.paths.join(', ')}`,
];
const action = async () => {
await modalState.fileChooser.setFiles(validatedParams.paths);
await modalState.fileChooser.setFiles(params.paths);
context.clearModalState(modalState);
};

View File

@@ -18,16 +18,14 @@ import { fork } from 'child_process';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool';
import type { Tool } from './tool';
const install: Tool = {
const install = defineTool({
capability: 'install',
schema: {
name: 'browser_install',
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
inputSchema: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
},
handle: async context => {
@@ -53,7 +51,7 @@ const install: Tool = {
waitForNetwork: false,
};
},
};
});
export default [
install,

View File

@@ -15,33 +15,28 @@
*/
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory } from './tool';
const pressKeySchema = z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
});
const pressKey: ToolFactory = captureSnapshot => ({
const pressKey: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_press_key',
description: 'Press a key on the keyboard',
inputSchema: zodToJsonSchema(pressKeySchema),
inputSchema: z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
}),
},
handle: async (context, params) => {
const validatedParams = pressKeySchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Press ${validatedParams.key}`,
`await page.keyboard.press('${validatedParams.key}');`,
`// Press ${params.key}`,
`await page.keyboard.press('${params.key}');`,
];
const action = () => tab.page.keyboard.press(validatedParams.key);
const action = () => tab.page.keyboard.press(params.key);
return {
code,

View File

@@ -15,31 +15,26 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory } from './tool';
const navigateSchema = z.object({
url: z.string().describe('The URL to navigate to'),
});
const navigate: ToolFactory = captureSnapshot => ({
const navigate: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_navigate',
description: 'Navigate to a URL',
inputSchema: zodToJsonSchema(navigateSchema),
inputSchema: z.object({
url: z.string().describe('The URL to navigate to'),
}),
},
handle: async (context, params) => {
const validatedParams = navigateSchema.parse(params);
const tab = await context.ensureTab();
await tab.navigate(validatedParams.url);
await tab.navigate(params.url);
const code = [
`// Navigate to ${validatedParams.url}`,
`await page.goto('${validatedParams.url}');`,
`// Navigate to ${params.url}`,
`await page.goto('${params.url}');`,
];
return {
@@ -50,14 +45,12 @@ const navigate: ToolFactory = captureSnapshot => ({
},
});
const goBackSchema = z.object({});
const goBack: ToolFactory = captureSnapshot => ({
const goBack: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
schema: {
name: 'browser_navigate_back',
description: 'Go back to the previous page',
inputSchema: zodToJsonSchema(goBackSchema),
inputSchema: z.object({}),
},
handle: async context => {
@@ -76,14 +69,12 @@ const goBack: ToolFactory = captureSnapshot => ({
},
});
const goForwardSchema = z.object({});
const goForward: ToolFactory = captureSnapshot => ({
const goForward: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
schema: {
name: 'browser_navigate_forward',
description: 'Go forward to the next page',
inputSchema: zodToJsonSchema(goForwardSchema),
inputSchema: z.object({}),
},
handle: async context => {
const tab = context.currentTabOrDie();

57
src/tools/network.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* 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 { z } from 'zod';
import { defineTool } from './tool';
import type * as playwright from 'playwright';
const requests = defineTool({
capability: 'core',
schema: {
name: 'browser_network_requests',
description: 'Returns all network requests since loading the page',
inputSchema: z.object({}),
},
handle: async context => {
const requests = context.currentTabOrDie().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,
};
},
});
function renderRequest(request: playwright.Request, response: playwright.Response | null) {
const result: string[] = [];
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
if (response)
result.push(`=> [${response.status()}] ${response.statusText()}`);
return result.join(' ');
}
export default [
requests,
];

View File

@@ -18,22 +18,18 @@ import os from 'os';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool';
import { sanitizeForFilePath } from './utils';
import * as javascript from '../javascript';
import type { Tool } from './tool';
const pdfSchema = z.object({});
const pdf: Tool = {
const pdf = defineTool({
capability: 'pdf',
schema: {
name: 'browser_pdf_save',
description: 'Save page as PDF',
inputSchema: zodToJsonSchema(pdfSchema),
inputSchema: z.object({}),
},
handle: async context => {
@@ -52,7 +48,7 @@ const pdf: Tool = {
waitForNetwork: false,
};
},
};
});
export default [
pdf,

View File

@@ -15,18 +15,20 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool';
import * as javascript from '../javascript';
import type { Tool } from './tool';
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const screenshot: Tool = {
const screenshot = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_capture',
description: 'Take a screenshot of the current page',
inputSchema: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
},
handle: async context => {
@@ -51,33 +53,26 @@ const screenshot: Tool = {
waitForNetwork: false
};
},
};
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const moveMouseSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
const moveMouse: Tool = {
const moveMouse = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_move_mouse',
description: 'Move mouse to a given position',
inputSchema: zodToJsonSchema(moveMouseSchema),
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
},
handle: async (context, params) => {
const validatedParams = moveMouseSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Move mouse to (${validatedParams.x}, ${validatedParams.y})`,
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
`// Move mouse to (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`,
];
const action = () => tab.page.mouse.move(validatedParams.x, validatedParams.y);
const action = () => tab.page.mouse.move(params.x, params.y);
return {
code,
action,
@@ -85,32 +80,29 @@ const moveMouse: Tool = {
waitForNetwork: false
};
},
};
const clickSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
const click: Tool = {
const click = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_click',
description: 'Click left mouse button',
inputSchema: zodToJsonSchema(clickSchema),
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
},
handle: async (context, params) => {
const validatedParams = clickSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Click mouse at coordinates (${validatedParams.x}, ${validatedParams.y})`,
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
`// 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 () => {
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
await tab.page.mouse.move(params.x, params.y);
await tab.page.mouse.down();
await tab.page.mouse.up();
};
@@ -121,40 +113,37 @@ const click: Tool = {
waitForNetwork: true,
};
},
};
const dragSchema = elementSchema.extend({
startX: z.number().describe('Start X coordinate'),
startY: z.number().describe('Start Y coordinate'),
endX: z.number().describe('End X coordinate'),
endY: z.number().describe('End Y coordinate'),
});
const drag: Tool = {
const drag = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_drag',
description: 'Drag left mouse button',
inputSchema: zodToJsonSchema(dragSchema),
inputSchema: elementSchema.extend({
startX: z.number().describe('Start X coordinate'),
startY: z.number().describe('Start Y coordinate'),
endX: z.number().describe('End X coordinate'),
endY: z.number().describe('End Y coordinate'),
}),
},
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Drag mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
`await page.mouse.move(${validatedParams.startX}, ${validatedParams.startY});`,
`// 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(${validatedParams.endX}, ${validatedParams.endY});`,
`await page.mouse.move(${params.endX}, ${params.endY});`,
`await page.mouse.up();`,
];
const action = async () => {
await tab.page.mouse.move(validatedParams.startX, validatedParams.startY);
await tab.page.mouse.move(params.startX, params.startY);
await tab.page.mouse.down();
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
await tab.page.mouse.move(params.endX, params.endY);
await tab.page.mouse.up();
};
@@ -165,38 +154,35 @@ const drag: Tool = {
waitForNetwork: true,
};
},
};
const typeSchema = z.object({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
});
const type: Tool = {
const type = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_type',
description: 'Type text',
inputSchema: zodToJsonSchema(typeSchema),
inputSchema: z.object({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
}),
},
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Type ${validatedParams.text}`,
`await page.keyboard.type('${validatedParams.text}');`,
`// Type ${params.text}`,
`await page.keyboard.type('${params.text}');`,
];
const action = async () => {
await tab.page.keyboard.type(validatedParams.text);
if (validatedParams.submit)
await tab.page.keyboard.type(params.text);
if (params.submit)
await tab.page.keyboard.press('Enter');
};
if (validatedParams.submit) {
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.keyboard.press('Enter');`);
}
@@ -208,7 +194,7 @@ const type: Tool = {
waitForNetwork: true,
};
},
};
});
export default [
screenshot,

View File

@@ -18,21 +18,20 @@ import path from 'path';
import os from 'os';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import { sanitizeForFilePath } from './utils';
import { generateLocator } from '../context';
import * as javascript from '../javascript';
import type * as playwright from 'playwright';
import type { Tool } from './tool';
import { defineTool } from './tool';
const snapshot: Tool = {
const snapshot = defineTool({
capability: 'core',
schema: {
name: 'browser_snapshot',
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
inputSchema: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
},
handle: async context => {
@@ -44,28 +43,27 @@ const snapshot: Tool = {
waitForNetwork: false,
};
},
};
});
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
ref: z.string().describe('Exact target element reference from the page snapshot'),
});
const click: Tool = {
const click = defineTool({
capability: 'core',
schema: {
name: 'browser_click',
description: 'Perform click on a web page',
inputSchema: zodToJsonSchema(elementSchema),
inputSchema: elementSchema,
},
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(validatedParams.ref);
const locator = tab.snapshotOrDie().refLocator(params.ref);
const code = [
`// Click ${validatedParams.element}`,
`// Click ${params.element}`,
`await page.${await generateLocator(locator)}.click();`
];
@@ -76,31 +74,28 @@ const click: Tool = {
waitForNetwork: true,
};
},
};
const dragSchema = z.object({
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
startRef: z.string().describe('Exact source element reference from the page snapshot'),
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
endRef: z.string().describe('Exact target element reference from the page snapshot'),
});
const drag: Tool = {
const drag = defineTool({
capability: 'core',
schema: {
name: 'browser_drag',
description: 'Perform drag and drop between two elements',
inputSchema: zodToJsonSchema(dragSchema),
inputSchema: z.object({
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
startRef: z.string().describe('Exact source element reference from the page snapshot'),
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
endRef: z.string().describe('Exact target element reference from the page snapshot'),
}),
},
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const startLocator = snapshot.refLocator(validatedParams.startRef);
const endLocator = snapshot.refLocator(validatedParams.endRef);
const startLocator = snapshot.refLocator(params.startRef);
const endLocator = snapshot.refLocator(params.endRef);
const code = [
`// Drag ${validatedParams.startElement} to ${validatedParams.endElement}`,
`// Drag ${params.startElement} to ${params.endElement}`,
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
];
@@ -111,23 +106,22 @@ const drag: Tool = {
waitForNetwork: true,
};
},
};
});
const hover: Tool = {
const hover = defineTool({
capability: 'core',
schema: {
name: 'browser_hover',
description: 'Hover over element on page',
inputSchema: zodToJsonSchema(elementSchema),
inputSchema: elementSchema,
},
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const locator = snapshot.refLocator(params.ref);
const code = [
`// Hover over ${validatedParams.element}`,
`// Hover over ${params.element}`,
`await page.${await generateLocator(locator)}.hover();`
];
@@ -138,7 +132,7 @@ const hover: Tool = {
waitForNetwork: true,
};
},
};
});
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
@@ -146,33 +140,32 @@ const typeSchema = elementSchema.extend({
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
});
const type: Tool = {
const type = defineTool({
capability: 'core',
schema: {
name: 'browser_type',
description: 'Type text into editable element',
inputSchema: zodToJsonSchema(typeSchema),
inputSchema: typeSchema,
},
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const locator = snapshot.refLocator(params.ref);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
if (validatedParams.slowly) {
code.push(`// Press "${validatedParams.text}" sequentially into "${validatedParams.element}"`);
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(validatedParams.text)});`);
steps.push(() => locator.pressSequentially(validatedParams.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 "${validatedParams.text}" into "${validatedParams.element}"`);
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(validatedParams.text)});`);
steps.push(() => locator.fill(validatedParams.text));
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 (validatedParams.submit) {
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
steps.push(() => locator.press('Enter'));
@@ -185,38 +178,37 @@ const type: Tool = {
waitForNetwork: true,
};
},
};
});
const selectOptionSchema = elementSchema.extend({
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
});
const selectOption: Tool = {
const selectOption = defineTool({
capability: 'core',
schema: {
name: 'browser_select_option',
description: 'Select an option in a dropdown',
inputSchema: zodToJsonSchema(selectOptionSchema),
inputSchema: selectOptionSchema,
},
handle: async (context, params) => {
const validatedParams = selectOptionSchema.parse(params);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const locator = snapshot.refLocator(params.ref);
const code = [
`// Select options [${validatedParams.values.join(', ')}] in ${validatedParams.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(validatedParams.values)});`
`// Select options [${params.values.join(', ')}] in ${params.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
];
return {
code,
action: () => locator.selectOption(validatedParams.values).then(() => {}),
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
},
};
});
const screenshotSchema = z.object({
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
@@ -229,28 +221,27 @@ const screenshotSchema = z.object({
path: ['ref', 'element']
});
const screenshot: Tool = {
const screenshot = defineTool({
capability: 'core',
schema: {
name: 'browser_take_screenshot',
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
inputSchema: zodToJsonSchema(screenshotSchema),
inputSchema: screenshotSchema,
},
handle: async (context, params) => {
const validatedParams = screenshotSchema.parse(params);
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = validatedParams.raw ? 'png' : 'jpeg';
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + `.${fileType}`;
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
const isElementScreenshot = validatedParams.element && validatedParams.ref;
const isElementScreenshot = params.element && params.ref;
const code = [
`// Screenshot ${isElementScreenshot ? validatedParams.element : 'viewport'} and save it as ${fileName}`,
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
];
const locator = validatedParams.ref ? snapshot.refLocator(validatedParams.ref) : null;
const locator = params.ref ? snapshot.refLocator(params.ref) : null;
if (locator)
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
@@ -275,7 +266,7 @@ const screenshot: Tool = {
waitForNetwork: false,
};
}
};
});
export default [

View File

@@ -15,17 +15,15 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory, Tool } from './tool';
const listTabs: Tool = {
const listTabs = defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_list',
description: 'List browser tabs',
inputSchema: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
},
handle: async context => {
@@ -42,26 +40,23 @@ const listTabs: Tool = {
},
};
},
};
const selectTabSchema = z.object({
index: z.number().describe('The index of the tab to select'),
});
const selectTab: ToolFactory = captureSnapshot => ({
const selectTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_select',
description: 'Select a tab by index',
inputSchema: zodToJsonSchema(selectTabSchema),
inputSchema: z.object({
index: z.number().describe('The index of the tab to select'),
}),
},
handle: async (context, params) => {
const validatedParams = selectTabSchema.parse(params);
await context.selectTab(validatedParams.index);
await context.selectTab(params.index);
const code = [
`// <internal code to select tab ${validatedParams.index}>`,
`// <internal code to select tab ${params.index}>`,
];
return {
@@ -72,24 +67,21 @@ const selectTab: ToolFactory = captureSnapshot => ({
},
});
const newTabSchema = z.object({
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
});
const newTab: ToolFactory = captureSnapshot => ({
const newTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_new',
description: 'Open a new tab',
inputSchema: zodToJsonSchema(newTabSchema),
inputSchema: z.object({
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
}),
},
handle: async (context, params) => {
const validatedParams = newTabSchema.parse(params);
await context.newTab();
if (validatedParams.url)
await context.currentTabOrDie().navigate(validatedParams.url);
if (params.url)
await context.currentTabOrDie().navigate(params.url);
const code = [
`// <internal code to open a new tab>`,
@@ -102,24 +94,21 @@ const newTab: ToolFactory = captureSnapshot => ({
},
});
const closeTabSchema = z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
});
const closeTab: ToolFactory = captureSnapshot => ({
const closeTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_close',
description: 'Close a tab',
inputSchema: zodToJsonSchema(closeTabSchema),
inputSchema: z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
}),
},
handle: async (context, params) => {
const validatedParams = closeTabSchema.parse(params);
await context.closeTab(validatedParams.index);
await context.closeTab(params.index);
const code = [
`// <internal code to close tab ${validatedParams.index}>`,
`// <internal code to close tab ${params.index}>`,
];
return {
code,

View File

@@ -15,17 +15,19 @@
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import type { z } from 'zod';
import type { Context } from '../context';
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
export type ToolSchema = {
export type ToolSchema<Input extends InputType> = {
name: string;
description: string;
inputSchema: JsonSchema7Type;
inputSchema: Input;
};
type InputType = z.Schema;
export type FileUploadModalState = {
type: 'fileChooser';
description: string;
@@ -50,11 +52,15 @@ export type ToolResult = {
resultOverride?: ToolActionResult;
};
export type Tool = {
export type Tool<Input extends InputType = InputType> = {
capability: ToolCapability;
schema: ToolSchema;
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
};
export type ToolFactory = (snapshot: boolean) => Tool;
export type ToolFactory = (snapshot: boolean) => Tool<any>;
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@@ -32,6 +32,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
@@ -56,6 +57,7 @@ test('test vision tool list', async ({ visionClient }) => {
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_pdf_save',
'browser_press_key',
'browser_resize',

View File

@@ -96,8 +96,8 @@ await page.getByRole('combobox').selectOption(['bar']);
- Page Snapshot
\`\`\`yaml
- combobox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
- option "Foo"
- option "Bar" [selected]
\`\`\`
`);
});
@@ -206,5 +206,5 @@ test('browser_resize', async ({ client }) => {
// Resize browser window to 390x780
await page.setViewportSize({ width: 390, height: 780 });
\`\`\``);
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent('Window size: 390x780');
});

View File

@@ -23,7 +23,12 @@ test('browser_file_upload', async ({ client }) => {
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
},
})).toContainTextContent('- textbox [ref=s1e3]');
})).toContainTextContent(`
\`\`\`yaml
- generic [ref=s1e2]:
- button "Choose File" [ref=s1e3]
- button "Button" [ref=s1e4]
\`\`\``);
expect(await client.callTool({
name: 'browser_click',
@@ -46,7 +51,12 @@ test('browser_file_upload', async ({ client }) => {
});
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
expect(response).toContainTextContent(`
\`\`\`yaml
- generic [ref=s3e2]:
- button "Choose File" [ref=s3e3]
- button "Button" [ref=s3e4]
\`\`\``);
}
{

View File

@@ -21,6 +21,7 @@ import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { spawn } from 'child_process';
import { TestServer } from './testserver';
type TestFixtures = {
client: Client;
@@ -28,11 +29,14 @@ type TestFixtures = {
startClient: (options?: { args?: string[] }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: string;
server: TestServer;
httpsServer: TestServer;
};
type WorkerFixtures = {
mcpHeadless: boolean;
mcpBrowser: string | undefined;
_workerServers: { server: TestServer, httpsServer: TestServer };
};
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
@@ -103,7 +107,32 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
await use(headless);
}, { scope: 'worker' }],
mcpBrowser: ['chromium', { option: true, scope: 'worker' }],
mcpBrowser: ['chrome', { option: true, scope: 'worker' }],
_workerServers: [async ({}, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(port);
const httpsPort = port + 1;
const httpsServer = await TestServer.createHTTPS(httpsPort);
await use({ server, httpsServer });
await Promise.all([
server.stop(),
httpsServer.stop(),
]);
}, { scope: 'worker' }],
server: async ({ _workerServers }, use) => {
_workerServers.server.reset();
await use(_workerServers.server);
},
httpsServer: async ({ _workerServers }, use) => {
_workerServers.httpsServer.reset();
await use(_workerServers.httpsServer);
},
});
type Response = Awaited<ReturnType<Client['callTool']>>;

View File

@@ -24,12 +24,14 @@ test('stitched aria frames', async ({ client }) => {
},
})).toContainTextContent(`
\`\`\`yaml
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
- generic [ref=s1e2]:
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- generic [ref=f1s1e2]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\``);
expect(await client.callTool({

View File

@@ -26,6 +26,7 @@ test('test reopen browser', async ({ client }) => {
expect(await client.callTool({
name: 'browser_close',
arguments: {},
})).toContainTextContent('No open pages available');
expect(await client.callTool({

49
tests/network.spec.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* 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 { test, expect } from './fixtures';
test('browser_network_requests', async ({ client, server }) => {
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<button onclick="fetch('/json')">Click me</button>`);
});
server.route('/json', (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ name: 'John Doe' }));
});
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me button',
ref: 's1e3',
},
});
expect.poll(() => client.callTool({
name: 'browser_network_requests',
arguments: {},
})).toHaveTextContent(`[GET] http://localhost:8907/json => [200] OK`);
});

View File

@@ -41,6 +41,7 @@ test('save as pdf', async ({ client, mcpBrowser }) => {
const response = await client.callTool({
name: 'browser_pdf_save',
arguments: {},
});
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
});

View File

@@ -32,6 +32,7 @@ async function createTab(client: Client, title: string, body: string) {
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
arguments: {},
})).toHaveTextContent(`### Open tabs
- 1: (current) [] (about:blank)`);
});
@@ -40,6 +41,7 @@ test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({
name: 'browser_tab_list',
arguments: {},
})).toHaveTextContent(`### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);

29
tests/testserver/cert.pem Normal file
View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX
DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv
Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr
ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ
9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj
NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw
alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV
dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP
dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM
38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4
kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15
D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D
G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG
iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y
1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth
KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o
XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf
pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf
JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to
ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40
AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg
hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy
BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg==
-----END CERTIFICATE-----

145
tests/testserver/index.ts Normal file
View File

@@ -0,0 +1,145 @@
/**
* Copyright 2017 Google Inc. All rights reserved.
* Modifications 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 fs from 'fs';
import http from 'http';
import https from 'https';
import path from 'path';
const fulfillSymbol = Symbol('fulfil callback');
const rejectSymbol = Symbol('reject callback');
export class TestServer {
private _server: http.Server;
readonly debugServer: any;
private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>();
private _csp = new Map<string, string>();
private _extraHeaders = new Map<string, object>();
private _requestSubscribers = new Map<string, Promise<any>>();
readonly PORT: number;
readonly PREFIX: string;
readonly CROSS_PROCESS_PREFIX: string;
static async create(port: number): Promise<TestServer> {
const server = new TestServer(port);
await new Promise(x => server._server.once('listening', x));
return server;
}
static async createHTTPS(port: number): Promise<TestServer> {
const server = new TestServer(port, {
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')),
passphrase: 'aaaa',
});
await new Promise(x => server._server.once('listening', x));
return server;
}
constructor(port: number, sslOptions?: object) {
if (sslOptions)
this._server = https.createServer(sslOptions, this._onRequest.bind(this));
else
this._server = http.createServer(this._onRequest.bind(this));
this._server.listen(port);
this.debugServer = require('debug')('pw:testserver');
const cross_origin = '127.0.0.1';
const same_origin = 'localhost';
const protocol = sslOptions ? 'https' : 'http';
this.PORT = port;
this.PREFIX = `${protocol}://${same_origin}:${port}`;
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
}
setCSP(path: string, csp: string) {
this._csp.set(path, csp);
}
setExtraHeaders(path: string, object: Record<string, string>) {
this._extraHeaders.set(path, object);
}
async stop() {
this.reset();
await new Promise(x => this._server.close(x));
}
route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) {
this._routes.set(path, handler);
}
redirect(from: string, to: string) {
this.route(from, (req, res) => {
const headers = this._extraHeaders.get(req.url!) || {};
res.writeHead(302, { ...headers, location: to });
res.end();
});
}
waitForRequest(path: string): Promise<http.IncomingMessage> {
let promise = this._requestSubscribers.get(path);
if (promise)
return promise;
let fulfill, reject;
promise = new Promise((f, r) => {
fulfill = f;
reject = r;
});
promise[fulfillSymbol] = fulfill;
promise[rejectSymbol] = reject;
this._requestSubscribers.set(path, promise);
return promise;
}
reset() {
this._routes.clear();
this._csp.clear();
this._extraHeaders.clear();
this._server.closeAllConnections();
const error = new Error('Static Server has been reset');
for (const subscriber of this._requestSubscribers.values())
subscriber[rejectSymbol].call(null, error);
this._requestSubscribers.clear();
}
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
request.on('error', error => {
if ((error as any).code === 'ECONNRESET')
response.end();
else
throw error;
});
(request as any).postBody = new Promise(resolve => {
const chunks: Buffer[] = [];
request.on('data', chunk => {
chunks.push(chunk);
});
request.on('end', () => resolve(Buffer.concat(chunks)));
});
const path = request.url || '/';
this.debugServer(`request ${request.method} ${path}`);
// Notify request subscriber.
if (this._requestSubscribers.has(path)) {
this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
this._requestSubscribers.delete(path);
}
const handler = this._routes.get(path);
if (handler)
handler.call(null, request, response);
}
}

52
tests/testserver/key.pem Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
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
ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ==
-----END PRIVATE KEY-----

19
tests/testserver/san.cnf Normal file
View File

@@ -0,0 +1,19 @@
# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = playwright-test
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
IP.2 = ::1

View File

@@ -18,6 +18,7 @@
const fs = require('node:fs');
const path = require('node:path');
const zodToJsonSchema = require('zod-to-json-schema').default;
const commonTools = require('../lib/tools/common').default;
const consoleTools = require('../lib/tools/console').default;
@@ -107,11 +108,11 @@ function formatToolForReadme(tool) {
*/
/**
* @param {import('../src/tools/tool').ToolSchema} schema
* @param {import('../src/tools/tool').ToolSchema<any>} schema
* @returns {ParsedToolSchema}
*/
function processToolSchema(schema) {
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (schema.inputSchema || {});
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ zodToJsonSchema(schema.inputSchema || {});
if (inputSchema.type !== 'object')
throw new Error(`Tool ${schema.name} input schema is not an object`);