From b1a0f775cfe64c8cba6e9e6e6415747bc58870e7 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 22 Jul 2025 20:06:03 -0700 Subject: [PATCH] chore: save session log (#740) --- README.md | 2 + config.d.ts | 5 +++ src/config.ts | 2 + src/connection.ts | 11 ++++-- src/program.ts | 1 + src/response.ts | 37 ++++++++++++++--- src/server.ts | 2 +- src/sessionLog.ts | 92 +++++++++++++++++++++++++++++++++++++++++++ src/tools/navigate.ts | 4 -- 9 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 src/sessionLog.ts diff --git a/README.md b/README.md index 631495a..01cc0f3 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ Playwright MCP server supports following arguments. They can be provided in the example ".com,chromium.org,.domain.com" --proxy-server specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080" + --save-session Whether to save the Playwright MCP session into + the output directory. --save-trace Whether to save the Playwright Trace of the session into the output directory. --storage-state path to the storage state file for isolated diff --git a/config.d.ts b/config.d.ts index 024c29a..d63b061 100644 --- a/config.d.ts +++ b/config.d.ts @@ -85,6 +85,11 @@ export type Config = { */ capabilities?: ToolCapability[]; + /** + * Whether to save the Playwright session into the output directory. + */ + saveSession?: boolean; + /** * Whether to save the Playwright trace of the session into the output directory. */ diff --git a/src/config.ts b/src/config.ts index a0e8680..4ed58c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,6 +42,7 @@ export type CLIOptions = { port?: number; proxyBypass?: string; proxyServer?: string; + saveSession?: boolean; saveTrace?: boolean; storageState?: string; userAgent?: string; @@ -189,6 +190,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { allowedOrigins: cliOptions.allowedOrigins, blockedOrigins: cliOptions.blockedOrigins, }, + saveSession: cliOptions.saveSession, saveTrace: cliOptions.saveTrace, outputDir: cliOptions.outputDir, imageResponses: cliOptions.imageResponses, diff --git a/src/connection.ts b/src/connection.ts index 570dc00..85147a0 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -22,12 +22,11 @@ import { Context } from './context.js'; import { Response } from './response.js'; import { allTools } from './tools.js'; import { packageJSON } from './package.js'; - import { FullConfig } from './config.js'; - +import { SessionLog } from './sessionLog.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; -export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection { +export async function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Promise { const tools = allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)); const context = new Context(tools, config, browserContextFactory); const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, { @@ -36,6 +35,8 @@ export function createConnection(config: FullConfig, browserContextFactory: Brow } }); + const sessionLog = config.saveSession ? await SessionLog.create(config) : undefined; + server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: tools.map(tool => ({ @@ -62,8 +63,10 @@ export function createConnection(config: FullConfig, browserContextFactory: Brow return errorResult(`Tool "${request.params.name}" not found`); try { - const response = new Response(context); + const response = new Response(context, request.params.name, request.params.arguments || {}); await tool.handle(context, tool.schema.inputSchema.parse(request.params.arguments || {}), response); + if (sessionLog) + await sessionLog.log(response); return await response.serialize(); } catch (error) { return errorResult(String(error)); diff --git a/src/program.ts b/src/program.ts index cfe0d5b..97ce84b 100644 --- a/src/program.ts +++ b/src/program.ts @@ -46,6 +46,7 @@ program .option('--port ', 'port to listen on for SSE transport.') .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') + .option('--save-session', 'Whether to save the Playwright MCP session into the output directory.') .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') .option('--storage-state ', 'path to the storage state file for isolated sessions.') .option('--user-agent ', 'specify user agent string') diff --git a/src/response.ts b/src/response.ts index d0fc25b..b2f6d56 100644 --- a/src/response.ts +++ b/src/response.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; +import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import type { Context } from './context.js'; export class Response { @@ -24,23 +24,41 @@ export class Response { private _context: Context; private _includeSnapshot = false; private _includeTabs = false; + private _snapshot: string | undefined; - constructor(context: Context) { + readonly toolName: string; + readonly toolArgs: Record; + + constructor(context: Context, toolName: string, toolArgs: Record) { this._context = context; + this.toolName = toolName; + this.toolArgs = toolArgs; } addResult(result: string) { this._result.push(result); } + result() { + return this._result.join('\n'); + } + addCode(code: string) { this._code.push(code); } + code() { + return this._code.join('\n'); + } + addImage(image: { contentType: string, data: Buffer }) { this._images.push(image); } + images() { + return this._images; + } + setIncludeSnapshot() { this._includeSnapshot = true; } @@ -49,8 +67,14 @@ export class Response { this._includeTabs = true; } - includeSnapshot() { - return this._includeSnapshot; + async snapshot(): Promise { + if (this._snapshot !== undefined) + return this._snapshot; + if (this._includeSnapshot && this._context.currentTab()) + this._snapshot = await this._context.currentTabOrDie().captureSnapshot(); + else + this._snapshot = ''; + return this._snapshot; } async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> { @@ -77,8 +101,9 @@ ${this._code.join('\n')} response.push(...(await this._context.listTabsMarkdown(this._includeTabs))); // Add snapshot if provided. - if (this._includeSnapshot && this._context.currentTab()) - response.push(await this._context.currentTabOrDie().captureSnapshot(), ''); + const snapshot = await this.snapshot(); + if (snapshot) + response.push(snapshot, ''); // Main response part const content: (TextContent | ImageContent)[] = [ diff --git a/src/server.ts b/src/server.ts index b54367e..4c20154 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,7 +35,7 @@ export class Server { } async createConnection(transport: Transport): Promise { - const connection = createConnection(this.config, this._contextFactory); + const connection = await createConnection(this.config, this._contextFactory); this._connectionList.push(connection); await connection.server.connect(transport); return connection; diff --git a/src/sessionLog.ts b/src/sessionLog.ts new file mode 100644 index 0000000..0dedab8 --- /dev/null +++ b/src/sessionLog.ts @@ -0,0 +1,92 @@ +/** + * 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 path from 'path'; + +import { outputFile } from './config.js'; +import { Response } from './response.js'; +import type { FullConfig } from './config.js'; + +let sessionOrdinal = 0; + +export class SessionLog { + private _folder: string; + private _file: string; + private _ordinal = 0; + + constructor(sessionFolder: string) { + this._folder = sessionFolder; + this._file = path.join(this._folder, 'session.md'); + } + + static async create(config: FullConfig): Promise { + const sessionFolder = await outputFile(config, `session-${(++sessionOrdinal).toString().padStart(3, '0')}`); + await fs.promises.mkdir(sessionFolder, { recursive: true }); + // eslint-disable-next-line no-console + console.error(`Session: ${sessionFolder}`); + return new SessionLog(sessionFolder); + } + + async log(response: Response) { + const prefix = `${(++this._ordinal).toString().padStart(3, '0')}`; + const lines: string[] = [ + `### Tool: ${response.toolName}`, + ``, + `- Args`, + '```json', + JSON.stringify(response.toolArgs, null, 2), + '```', + ]; + if (response.result()) { + lines.push( + `- Result`, + '```', + response.result(), + '```'); + } + + if (response.code()) { + lines.push( + `- Code`, + '```js', + response.code(), + '```'); + } + + const snapshot = await response.snapshot(); + if (snapshot) { + const fileName = `${prefix}.snapshot.yml`; + await fs.promises.writeFile(path.join(this._folder, fileName), snapshot); + lines.push(`- Snapshot: ${fileName}`); + } + + for (const image of response.images()) { + const fileName = `${prefix}.screenshot.${extension(image.contentType)}`; + await fs.promises.writeFile(path.join(this._folder, fileName), image.data); + lines.push(`- Screenshot: ${fileName}`); + } + + lines.push('', ''); + await fs.promises.appendFile(this._file, lines.join('\n')); + } +} + +function extension(contentType: string): 'jpg' | 'png' { + if (contentType === 'image/jpeg') + return 'jpg'; + return 'png'; +} diff --git a/src/tools/navigate.ts b/src/tools/navigate.ts index ea09930..f69d8e2 100644 --- a/src/tools/navigate.ts +++ b/src/tools/navigate.ts @@ -51,8 +51,6 @@ const goBack = defineTabTool({ }, handle: async (tab, params, response) => { - response.setIncludeSnapshot(); - await tab.page.goBack(); response.setIncludeSnapshot(); response.addCode(`// Navigate back`); @@ -70,8 +68,6 @@ const goForward = defineTabTool({ type: 'readOnly', }, handle: async (tab, params, response) => { - response.setIncludeSnapshot(); - await tab.page.goForward(); response.setIncludeSnapshot(); response.addCode(`// Navigate forward`);