chore: save session log (#740)

This commit is contained in:
Pavel Feldman 2025-07-22 20:06:03 -07:00 committed by GitHub
parent 6320b08173
commit b1a0f775cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 141 additions and 15 deletions

View File

@ -162,6 +162,8 @@ Playwright MCP server supports following arguments. They can be provided in the
example ".com,chromium.org,.domain.com"
--proxy-server <proxy> 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> path to the storage state file for isolated

5
config.d.ts vendored
View File

@ -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.
*/

View File

@ -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,

View File

@ -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<Connection> {
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));

View File

@ -46,6 +46,7 @@ program
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', '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>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string')

View File

@ -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<string, any>;
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
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<string> {
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)[] = [

View File

@ -35,7 +35,7 @@ export class Server {
}
async createConnection(transport: Transport): Promise<Connection> {
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;

92
src/sessionLog.ts Normal file
View File

@ -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<SessionLog> {
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';
}

View File

@ -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`);