From 0a3387fda38b70450220ed285a0abd1859feb106 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 18 Mar 2025 15:23:47 -0700 Subject: [PATCH] chore: add mcp resources (#35257) --- packages/playwright-mcp/src/context.ts | 65 ++++++++++++++ packages/playwright-mcp/src/program.ts | 6 +- .../resources/{resources.ts => console.ts} | 26 +++--- .../playwright-mcp/src/resources/resource.ts | 8 +- packages/playwright-mcp/src/server.ts | 40 ++------- packages/playwright-mcp/src/tools/common.ts | 89 ++++++++++++++----- .../playwright-mcp/src/tools/screenshot.ts | 34 +++---- packages/playwright-mcp/src/tools/snapshot.ts | 20 ++--- packages/playwright-mcp/src/tools/tool.ts | 8 +- packages/playwright-mcp/src/tools/utils.ts | 9 +- 10 files changed, 197 insertions(+), 108 deletions(-) create mode 100644 packages/playwright-mcp/src/context.ts rename packages/playwright-mcp/src/resources/{resources.ts => console.ts} (59%) diff --git a/packages/playwright-mcp/src/context.ts b/packages/playwright-mcp/src/context.ts new file mode 100644 index 0000000000..faf20f1989 --- /dev/null +++ b/packages/playwright-mcp/src/context.ts @@ -0,0 +1,65 @@ +/** + * 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 * as playwright from 'playwright'; + +export class Context { + private _launchOptions: playwright.LaunchOptions; + private _page: playwright.Page | undefined; + private _console: playwright.ConsoleMessage[] = []; + private _initializePromise: Promise | undefined; + + constructor(launchOptions: playwright.LaunchOptions) { + this._launchOptions = launchOptions; + } + + async ensurePage(): Promise { + await this._initialize(); + return this._page!; + } + + async ensureConsole(): Promise { + await this._initialize(); + return this._console; + } + + async close() { + const page = await this.ensurePage(); + await page.close(); + this._initializePromise = undefined; + } + + private async _initialize() { + if (this._initializePromise) + return this._initializePromise; + this._initializePromise = (async () => { + const browser = await this._createBrowser(); + this._page = await browser.newPage(); + this._page.on('console', event => this._console.push(event)); + this._page.on('framenavigated', () => this._console.length = 0); + })(); + return this._initializePromise; + } + + private async _createBrowser(): Promise { + if (process.env.PLAYWRIGHT_WS_ENDPOINT) { + const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT); + url.searchParams.set('launch-options', JSON.stringify(this._launchOptions)); + return await playwright.chromium.connect(String(url)); + } + return await playwright.chromium.launch(this._launchOptions); + } +} diff --git a/packages/playwright-mcp/src/program.ts b/packages/playwright-mcp/src/program.ts index fecd43c1c8..8bb5269c31 100644 --- a/packages/playwright-mcp/src/program.ts +++ b/packages/playwright-mcp/src/program.ts @@ -20,7 +20,7 @@ import { Server } from './server'; import * as snapshot from './tools/snapshot'; import * as common from './tools/common'; import * as screenshot from './tools/screenshot'; -import * as allResources from './resources/resources'; +import { console } from './resources/console'; import type { LaunchOptions } from './server'; import type { Tool } from './tools/tool'; @@ -62,6 +62,8 @@ function setupExitWatchdog(server: Server) { const commonTools: Tool[] = [ common.pressKey, common.wait, + common.pdf, + common.close, ]; const snapshotTools: Tool[] = [ @@ -88,7 +90,7 @@ const screenshotTools: Tool[] = [ ]; const resources: Resource[] = [ - allResources.pdf, + console, ]; program.parse(process.argv); diff --git a/packages/playwright-mcp/src/resources/resources.ts b/packages/playwright-mcp/src/resources/console.ts similarity index 59% rename from packages/playwright-mcp/src/resources/resources.ts rename to packages/playwright-mcp/src/resources/console.ts index 88edad2dc3..14533f4d2b 100644 --- a/packages/playwright-mcp/src/resources/resources.ts +++ b/packages/playwright-mcp/src/resources/console.ts @@ -14,22 +14,24 @@ * limitations under the License. */ -import type { Resource } from './resource'; +import type { Resource, ResourceResult } from './resource'; -export const pdf: Resource = { +export const console: Resource = { schema: { - uri: 'file:///playwright/page.pdf', - name: 'Page as PDF', - description: 'Save current page as PDF', - mimeType: 'application/pdf', + uri: 'browser://console', + name: 'Page console', + mimeType: 'text/plain', }, read: async (context, uri) => { - const pdf = await context.page.pdf(); - return { - uri, - mimeType: 'application/pdf', - blob: pdf.toString('base64'), - }; + const result: ResourceResult[] = []; + for (const message of await context.ensureConsole()) { + result.push({ + uri, + mimeType: 'text/plain', + text: `[${message.type().toUpperCase()}] ${message.text()}`, + }); + } + return result; }, }; diff --git a/packages/playwright-mcp/src/resources/resource.ts b/packages/playwright-mcp/src/resources/resource.ts index 2f45c4dc0b..d9b19e4dfb 100644 --- a/packages/playwright-mcp/src/resources/resource.ts +++ b/packages/playwright-mcp/src/resources/resource.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -import type * as playwright from 'playwright'; - -export type ResourceContext = { - page: playwright.Page; -}; +import type { Context } from '../context'; export type ResourceSchema = { uri: string; @@ -36,5 +32,5 @@ export type ResourceResult = { export type Resource = { schema: ResourceSchema; - read: (context: ResourceContext, uri: string) => Promise; + read: (context: Context, uri: string) => Promise; }; diff --git a/packages/playwright-mcp/src/server.ts b/packages/playwright-mcp/src/server.ts index 42e4888e20..3e792123a4 100644 --- a/packages/playwright-mcp/src/server.ts +++ b/packages/playwright-mcp/src/server.ts @@ -19,6 +19,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import * as playwright from 'playwright'; +import { Context } from './context'; + import type { Tool } from './tools/tool'; import type { Resource } from './resources/resource'; @@ -30,11 +32,11 @@ export class Server { private _server: MCPServer; private _tools: Tool[]; private _page: playwright.Page | undefined; - private _launchOptions: LaunchOptions; + private _context: Context; constructor(options: { name: string, version: string, tools: Tool[], resources: Resource[] }, launchOptions: LaunchOptions) { const { name, version, tools, resources } = options; - this._launchOptions = launchOptions; + this._context = new Context(launchOptions); this._server = new MCPServer({ name, version }, { capabilities: { tools: {}, @@ -52,8 +54,6 @@ export class Server { }); this._server.setRequestHandler(CallToolRequestSchema, async request => { - const page = await this._openPage(); - const tool = this._tools.find(tool => tool.schema.name === request.params.name); if (!tool) { return { @@ -63,7 +63,7 @@ export class Server { } try { - const result = await tool.handle({ page }, request.params.arguments); + const result = await tool.handle(this._context, request.params.arguments); return result; } catch (error) { return { @@ -75,15 +75,11 @@ export class Server { this._server.setRequestHandler(ReadResourceRequestSchema, async request => { const resource = resources.find(resource => resource.schema.uri === request.params.uri); - if (!resource) { - return { - content: [{ type: 'text', text: `Resource "${request.params.uri}" not found` }], - isError: true, - }; - } + if (!resource) + return { contents: [] }; - const result = await resource.read({ page: await this._openPage() }, request.params.uri); - return result; + const contents = await resource.read(this._context, request.params.uri); + return { contents }; }); } @@ -96,22 +92,4 @@ export class Server { await this._server.close(); await this._page?.context()?.browser()?.close(); } - - private async _createBrowser(): Promise { - if (process.env.PLAYWRIGHT_WS_ENDPOINT) { - const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT); - url.searchParams.set('launch-options', JSON.stringify(this._launchOptions)); - return await playwright.chromium.connect(String(url)); - } - return await playwright.chromium.launch(this._launchOptions); - } - - private async _openPage(): Promise { - if (!this._page) { - const browser = await this._createBrowser(); - const context = await browser.newContext(); - this._page = await context.newPage(); - } - return this._page; - } } diff --git a/packages/playwright-mcp/src/tools/common.ts b/packages/playwright-mcp/src/tools/common.ts index ff2323656b..4fc7bd5168 100644 --- a/packages/playwright-mcp/src/tools/common.ts +++ b/packages/playwright-mcp/src/tools/common.ts @@ -14,10 +14,13 @@ * limitations under the License. */ +import os from 'os'; +import path from 'path'; + import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; -import { runAndWait } from './utils'; +import { captureAriaSnapshot, runAndWait } from './utils'; import type { ToolFactory, Tool } from './tool'; @@ -27,21 +30,24 @@ const navigateSchema = z.object({ export const navigate: ToolFactory = snapshot => ({ schema: { - name: 'navigate', + name: 'browser_navigate', description: 'Navigate to a URL', inputSchema: zodToJsonSchema(navigateSchema), }, handle: async (context, params) => { const validatedParams = navigateSchema.parse(params); - return await runAndWait(context, async () => { - await context.page.goto(validatedParams.url); - return { - content: [{ - type: 'text', - text: `Navigated to ${validatedParams.url}`, - }], - }; - }, snapshot); + const page = await context.ensurePage(); + await page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' }); + // Cap load event to 5 seconds, the page is operational at this point. + await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); + if (snapshot) + return captureAriaSnapshot(page); + return { + content: [{ + type: 'text', + text: `Navigated to ${validatedParams.url}`, + }], + }; }, }); @@ -49,13 +55,14 @@ const goBackSchema = z.object({}); export const goBack: ToolFactory = snapshot => ({ schema: { - name: 'goBack', + name: 'browser_go_back', description: 'Go back to the previous page', inputSchema: zodToJsonSchema(goBackSchema), }, handle: async context => { return await runAndWait(context, async () => { - await context.page.goBack(); + const page = await context.ensurePage(); + await page.goBack(); return { content: [{ type: 'text', @@ -70,13 +77,14 @@ const goForwardSchema = z.object({}); export const goForward: ToolFactory = snapshot => ({ schema: { - name: 'goForward', + name: 'browser_go_forward', description: 'Go forward to the next page', inputSchema: zodToJsonSchema(goForwardSchema), }, handle: async context => { return await runAndWait(context, async () => { - await context.page.goForward(); + const page = await context.ensurePage(); + await page.goForward(); return { content: [{ type: 'text', @@ -93,13 +101,14 @@ const waitSchema = z.object({ export const wait: Tool = { schema: { - name: 'wait', + name: 'browser_wait', description: 'Wait for a specified time in seconds', inputSchema: zodToJsonSchema(waitSchema), }, handle: async (context, params) => { const validatedParams = waitSchema.parse(params); - await context.page.waitForTimeout(Math.min(10000, validatedParams.time * 1000)); + const page = await context.ensurePage(); + await page.waitForTimeout(Math.min(10000, validatedParams.time * 1000)); return { content: [{ type: 'text', @@ -115,14 +124,14 @@ const pressKeySchema = z.object({ export const pressKey: Tool = { schema: { - name: 'press', + name: 'browser_press_key', description: 'Press a key on the keyboard', inputSchema: zodToJsonSchema(pressKeySchema), }, handle: async (context, params) => { const validatedParams = pressKeySchema.parse(params); - return await runAndWait(context, async () => { - await context.page.keyboard.press(validatedParams.key); + return await runAndWait(context, async page => { + await page.keyboard.press(validatedParams.key); return { content: [{ type: 'text', @@ -132,3 +141,43 @@ export const pressKey: Tool = { }); }, }; + +const pdfSchema = z.object({}); + +export const pdf: Tool = { + schema: { + name: 'browser_save_as_pdf', + description: 'Save page as PDF', + inputSchema: zodToJsonSchema(pdfSchema), + }, + handle: async context => { + const page = await context.ensurePage(); + const fileName = path.join(os.tmpdir(), `/page-${new Date().toISOString()}.pdf`); + await page.pdf({ path: fileName }); + return { + content: [{ + type: 'text', + text: `Saved as ${fileName}`, + }], + }; + }, +}; + +const closeSchema = z.object({}); + +export const close: Tool = { + schema: { + name: 'browser_close', + description: 'Close the page', + inputSchema: zodToJsonSchema(closeSchema), + }, + handle: async context => { + await context.close(); + return { + content: [{ + type: 'text', + text: `Page closed`, + }], + }; + }, +}; diff --git a/packages/playwright-mcp/src/tools/screenshot.ts b/packages/playwright-mcp/src/tools/screenshot.ts index 8dff613900..bb78ce79e4 100644 --- a/packages/playwright-mcp/src/tools/screenshot.ts +++ b/packages/playwright-mcp/src/tools/screenshot.ts @@ -23,13 +23,14 @@ import type { Tool } from './tool'; export const screenshot: Tool = { schema: { - name: 'screenshot', + name: 'browser_screenshot', description: 'Take a screenshot of the current page', inputSchema: zodToJsonSchema(z.object({})), }, handle: async context => { - const screenshot = await context.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' }); + const page = await context.ensurePage(); + const screenshot = await page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' }); return { content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }], }; @@ -47,14 +48,15 @@ const moveMouseSchema = elementSchema.extend({ export const moveMouse: Tool = { schema: { - name: 'move_mouse', + name: 'browser_move_mouse', description: 'Move mouse to a given position', inputSchema: zodToJsonSchema(moveMouseSchema), }, handle: async (context, params) => { const validatedParams = moveMouseSchema.parse(params); - await context.page.mouse.move(validatedParams.x, validatedParams.y); + const page = await context.ensurePage(); + await page.mouse.move(validatedParams.x, validatedParams.y); return { content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }], }; @@ -63,15 +65,15 @@ export const moveMouse: Tool = { export const click: Tool = { schema: { - name: 'click', + name: 'browser_click', description: 'Click left mouse button', inputSchema: zodToJsonSchema(elementSchema), }, handle: async context => { - await runAndWait(context, async () => { - await context.page.mouse.down(); - await context.page.mouse.up(); + await runAndWait(context, async page => { + await page.mouse.down(); + await page.mouse.up(); }); return { content: [{ type: 'text', text: 'Clicked mouse' }], @@ -86,17 +88,17 @@ const dragSchema = elementSchema.extend({ export const drag: Tool = { schema: { - name: 'drag', + name: 'browser_drag', description: 'Drag left mouse button', inputSchema: zodToJsonSchema(dragSchema), }, handle: async (context, params) => { const validatedParams = dragSchema.parse(params); - await runAndWait(context, async () => { - await context.page.mouse.down(); - await context.page.mouse.move(validatedParams.x, validatedParams.y); - await context.page.mouse.up(); + await runAndWait(context, async page => { + await page.mouse.down(); + await page.mouse.move(validatedParams.x, validatedParams.y); + await page.mouse.up(); }); return { content: [{ type: 'text', text: `Dragged mouse to (${validatedParams.x}, ${validatedParams.y})` }], @@ -110,15 +112,15 @@ const typeSchema = z.object({ export const type: Tool = { schema: { - name: 'type', + name: 'browser_type', description: 'Type text', inputSchema: zodToJsonSchema(typeSchema), }, handle: async (context, params) => { const validatedParams = typeSchema.parse(params); - await runAndWait(context, async () => { - await context.page.keyboard.type(validatedParams.text); + await runAndWait(context, async page => { + await page.keyboard.type(validatedParams.text); }); return { content: [{ type: 'text', text: `Typed text "${validatedParams.text}"` }], diff --git a/packages/playwright-mcp/src/tools/snapshot.ts b/packages/playwright-mcp/src/tools/snapshot.ts index 301a4db366..7614eeaba4 100644 --- a/packages/playwright-mcp/src/tools/snapshot.ts +++ b/packages/playwright-mcp/src/tools/snapshot.ts @@ -24,13 +24,13 @@ import type { Tool } from './tool'; export const snapshot: Tool = { schema: { - name: 'snapshot', + name: 'browser_snapshot', description: 'Capture accessibility snapshot of the current page, this is better than screenshot', inputSchema: zodToJsonSchema(z.object({})), }, handle: async context => { - return await captureAriaSnapshot(context.page); + return await captureAriaSnapshot(await context.ensurePage()); }, }; @@ -41,29 +41,27 @@ const elementSchema = z.object({ export const click: Tool = { schema: { - name: 'click', + name: 'browser_click', description: 'Perform click on a web page', inputSchema: zodToJsonSchema(elementSchema), }, handle: async (context, params) => { const validatedParams = elementSchema.parse(params); - const locator = refLocator(context.page, validatedParams); - return runAndWait(context, () => locator.click(), true); + return runAndWait(context, page => refLocator(page, validatedParams).click(), true); }, }; export const hover: Tool = { schema: { - name: 'hover', + name: 'browser_hover', description: 'Hover over element on page', inputSchema: zodToJsonSchema(elementSchema), }, handle: async (context, params) => { const validatedParams = elementSchema.parse(params); - const locator = refLocator(context.page, validatedParams); - return runAndWait(context, () => locator.hover(), true); + return runAndWait(context, page => refLocator(page, validatedParams).hover(), true); }, }; @@ -74,15 +72,15 @@ const typeSchema = elementSchema.extend({ export const type: Tool = { schema: { - name: 'type', + name: 'browser_type', description: 'Type text into editable element', inputSchema: zodToJsonSchema(typeSchema), }, handle: async (context, params) => { const validatedParams = typeSchema.parse(params); - const locator = refLocator(context.page, validatedParams); - return await runAndWait(context, async () => { + return await runAndWait(context, async page => { + const locator = refLocator(page, validatedParams); await locator.fill(validatedParams.text); if (validatedParams.submit) await locator.press('Enter'); diff --git a/packages/playwright-mcp/src/tools/tool.ts b/packages/playwright-mcp/src/tools/tool.ts index f85702c448..19f1b608c3 100644 --- a/packages/playwright-mcp/src/tools/tool.ts +++ b/packages/playwright-mcp/src/tools/tool.ts @@ -14,13 +14,9 @@ * limitations under the License. */ -import type * as playwright from 'playwright'; import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types'; import type { JsonSchema7Type } from 'zod-to-json-schema'; - -export type ToolContext = { - page: playwright.Page; -}; +import type { Context } from '../context'; export type ToolSchema = { name: string; @@ -35,7 +31,7 @@ export type ToolResult = { export type Tool = { schema: ToolSchema; - handle: (context: ToolContext, params?: Record) => Promise; + handle: (context: Context, params?: Record) => Promise; }; export type ToolFactory = (snapshot: boolean) => Tool; diff --git a/packages/playwright-mcp/src/tools/utils.ts b/packages/playwright-mcp/src/tools/utils.ts index 5ff7a5e37d..9fca6765fa 100644 --- a/packages/playwright-mcp/src/tools/utils.ts +++ b/packages/playwright-mcp/src/tools/utils.ts @@ -15,7 +15,8 @@ */ import type * as playwright from 'playwright'; -import type { ToolContext, ToolResult } from './tool'; +import type { ToolResult } from './tool'; +import type { Context } from '../context'; async function waitForCompletion(page: playwright.Page, callback: () => Promise): Promise { const requests = new Set(); @@ -70,9 +71,9 @@ async function waitForCompletion(page: playwright.Page, callback: () => Promi } } -export async function runAndWait(context: ToolContext, callback: () => Promise, snapshot: boolean = false): Promise { - const page = context.page; - const result = await waitForCompletion(page, () => callback()); +export async function runAndWait(context: Context, callback: (page: playwright.Page) => Promise, snapshot: boolean = false): Promise { + const page = await context.ensurePage(); + const result = await waitForCompletion(page, () => callback(page)); return snapshot ? captureAriaSnapshot(page) : result; }