Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions packages/playwright/src/mcp/browser/browserContextFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ import { registryDirectory } from 'playwright-core/lib/server/registry/index';
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { findBrowserProcess, getBrowserExecPath } from './processUtils';
import { logUnhandledError, testDebug } from '../log';
import { outputFile } from './config';
import { outputFile } from './config';
import { firstRootPath } from '../sdk/server';

import type { FullConfig } from './config';
import type { LaunchOptions } from '../../../../playwright-core/src/client/types';
import type { ClientInfo } from '../sdk/server';

export function contextFactory(config: FullConfig): BrowserContextFactory {
if (config.browser.remoteEndpoint)
Expand All @@ -39,8 +41,6 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
return new PersistentContextFactory(config);
}

export type ClientInfo = { name?: string, version?: string, rootPath?: string };

export interface BrowserContextFactory {
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
}
Expand Down Expand Up @@ -105,7 +105,7 @@ class IsolatedContextFactory extends BaseContextFactory {
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
await injectCdpPort(this.config.browser);
const browserType = playwright[this.config.browser.browserName];
const tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces`);
const tracesDir = await outputFile(this.config, clientInfo, `traces`);
if (this.config.saveTrace)
await startTraceServer(this.config, tracesDir);
return browserType.launch({
Expand Down Expand Up @@ -171,8 +171,8 @@ class PersistentContextFactory implements BrowserContextFactory {
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
await injectCdpPort(this.config.browser);
testDebug('create browser context (persistent)');
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
const tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces`);
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo);
const tracesDir = await outputFile(this.config, clientInfo, `traces`);
if (this.config.saveTrace)
await startTraceServer(this.config, tracesDir);

Expand Down Expand Up @@ -218,10 +218,11 @@ class PersistentContextFactory implements BrowserContextFactory {
testDebug('close browser context complete (persistent)');
}

private async _createUserDataDir(rootPath: string | undefined) {
private async _createUserDataDir(clientInfo: ClientInfo) {
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
const rootPath = firstRootPath(clientInfo);
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
await fs.promises.mkdir(result, { recursive: true });
Expand Down
13 changes: 3 additions & 10 deletions packages/playwright/src/mcp/browser/browserServerBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
* limitations under the License.
*/

import { fileURLToPath } from 'url';
import { FullConfig } from './config';
import { Context } from './context';
import { logUnhandledError } from '../log';
Expand All @@ -41,19 +40,13 @@ export class BrowserServerBackend implements ServerBackend {
this._tools = filteredTools(config);
}

async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
let rootPath: string | undefined;
if (roots.length > 0) {
const firstRootUri = roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined;
rootPath = url ? fileURLToPath(url) : undefined;
}
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
async initialize(server: mcpServer.Server, clientInfo: mcpServer.ClientInfo): Promise<void> {
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, clientInfo) : undefined;
this._context = new Context({
config: this._config,
browserContextFactory: this._browserContextFactory,
sessionLog: this._sessionLog,
clientInfo: { ...clientVersion, rootPath },
clientInfo,
});
}

Expand Down
9 changes: 7 additions & 2 deletions packages/playwright/src/mcp/browser/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@
import fs from 'fs';
import os from 'os';
import path from 'path';

import { devices } from 'playwright-core';
import { dotenv } from 'playwright-core/lib/utilsBundle';

import { firstRootPath } from '../sdk/server';

import type * as playwright from '../../../types/test';
import type { Config, ToolCapability } from '../config';
import type { ClientInfo } from '../sdk/server';

export type CLIOptions = {
allowedOrigins?: string[];
Expand Down Expand Up @@ -265,10 +269,11 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
}
}

export async function outputFile(config: FullConfig, rootPath: string | undefined, name: string): Promise<string> {
export async function outputFile(config: FullConfig, clientInfo: ClientInfo, name: string): Promise<string> {
const rootPath = firstRootPath(clientInfo);
const outputDir = config.outputDir
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
?? path.join(process.env.PW_TMPDIR_FOR_TEST ?? os.tmpdir(), 'playwright-mcp-output', String(clientInfo.timestamp));

await fs.promises.mkdir(outputDir, { recursive: true });
const fileName = sanitizeForFilePath(name);
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright/src/mcp/browser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import * as codegen from './codegen';

import type * as playwright from '../../../types/test';
import type { FullConfig } from './config';
import type { BrowserContextFactory, ClientInfo } from './browserContextFactory';
import type { BrowserContextFactory } from './browserContextFactory';
import type * as actions from './actions';
import type { SessionLog } from './sessionLog';
import type { Tracing } from '../../../../playwright-core/src/client/tracing';
import type { ClientInfo } from '../sdk/server';

const testDebug = debug('pw:mcp:test');

Expand Down Expand Up @@ -113,7 +114,7 @@ export class Context {
}

async outputFile(name: string): Promise<string> {
return outputFile(this.config, this._clientInfo.rootPath, name);
return outputFile(this.config, this._clientInfo, name);
}

private _onPageCreated(page: playwright.Page) {
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright/src/mcp/browser/sessionLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { outputFile } from './config';
import type { FullConfig } from './config';
import type * as actions from './actions';
import type { Tab, TabSnapshot } from './tab';
import type * as mcpServer from '../sdk/server';

type LogEntry = {
timestamp: number;
Expand Down Expand Up @@ -51,8 +52,8 @@ export class SessionLog {
this._file = path.join(this._folder, 'session.md');
}

static async create(config: FullConfig, rootPath: string | undefined): Promise<SessionLog> {
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
static async create(config: FullConfig, clientInfo: mcpServer.ClientInfo): Promise<SessionLog> {
const sessionFolder = await outputFile(config, clientInfo, `session-${Date.now()}`);
await fs.promises.mkdir(sessionFolder, { recursive: true });
// eslint-disable-next-line no-console
console.error(`Session: ${sessionFolder}`);
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/mcp/extension/cdpRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { logUnhandledError } from '../log';
import * as protocol from './protocol';

import type websocket from 'ws';
import type { ClientInfo } from '../browser/browserContextFactory';
import type { ClientInfo } from '../sdk/server';
import type { ExtensionCommand, ExtensionEvents } from './protocol';
import type { WebSocket, WebSocketServer } from 'playwright-core/lib/utilsBundle';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { debug } from 'playwright-core/lib/utilsBundle';
import { startHttpServer } from '../sdk/http';
import { CDPRelayServer } from './cdpRelay';

import type { BrowserContextFactory, ClientInfo } from '../browser/browserContextFactory';
import type { BrowserContextFactory } from '../browser/browserContextFactory';
import type { ClientInfo } from '../sdk/server';

const debugLogger = debug('pw:mcp:relay');

Expand Down
18 changes: 9 additions & 9 deletions packages/playwright/src/mcp/sdk/mdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ export class MDBBackend implements mcpServer.ServerBackend {
private _stack: { client: Client, toolNames: string[], resultPromise: ManualPromise<mcpServer.CallToolResult> | undefined }[] = [];
private _interruptPromise: ManualPromise<mcpServer.CallToolResult> | undefined;
private _topLevelBackend: mcpServer.ServerBackend;
private _roots: mcpServer.Root[] | undefined;
private _clientInfo: mcpServer.ClientInfo | undefined;

constructor(topLevelBackend: mcpServer.ServerBackend) {
this._topLevelBackend = topLevelBackend;
}

async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
if (!this._roots)
this._roots = roots;
async initialize(server: mcpServer.Server, clientInfo: mcpServer.ClientInfo): Promise<void> {
if (!this._clientInfo)
this._clientInfo = clientInfo;
}

async listTools(): Promise<mcpServer.Tool[]> {
Expand Down Expand Up @@ -107,8 +107,8 @@ export class MDBBackend implements mcpServer.ServerBackend {

private async _pushClient(transport: Transport, introMessage?: string): Promise<mcpServer.CallToolResult> {
mdbDebug('pushing client to the stack');
const client = new mcpBundle.Client({ name: 'Internal client', version: '0.0.0' }, { capabilities: { roots: {} } });
client.setRequestHandler(mcpBundle.ListRootsRequestSchema, () => ({ roots: this._roots || [] }));
const client = new mcpBundle.Client({ name: 'Pushing client', version: '0.0.0' }, { capabilities: { roots: {} } });
client.setRequestHandler(mcpBundle.ListRootsRequestSchema, () => ({ roots: this._clientInfo?.roots || [] }));
client.setRequestHandler(mcpBundle.PingRequestSchema, () => ({}));
await client.connect(transport);
mdbDebug('connected to the new client');
Expand Down Expand Up @@ -169,7 +169,7 @@ export async function runOnPauseBackendLoop(backend: mcpServer.ServerBackend, in
await mcpHttp.installHttpTransport(httpServer, factory);
const url = mcpHttp.httpAddressToString(httpServer.address());

const client = new mcpBundle.Client({ name: 'Internal client', version: '0.0.0' });
const client = new mcpBundle.Client({ name: 'On-pause client', version: '0.0.0' });
client.setRequestHandler(mcpBundle.PingRequestSchema, () => ({}));
const transport = new mcpBundle.StreamableHTTPClientTransport(new URL(process.env.PLAYWRIGHT_DEBUGGER_MCP!));
await client.connect(transport);
Expand Down Expand Up @@ -205,8 +205,8 @@ class ServerBackendWithCloseListener implements mcpServer.ServerBackend {
this._backend = backend;
}

async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
await this._backend.initialize?.(server, clientVersion, roots);
async initialize(server: mcpServer.Server, clientInfo: mcpServer.ClientInfo): Promise<void> {
await this._backend.initialize?.(server, clientInfo);
}

async listTools(): Promise<mcpServer.Tool[]> {
Expand Down
10 changes: 5 additions & 5 deletions packages/playwright/src/mcp/sdk/proxyBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { debug } from 'playwright-core/lib/utilsBundle';

import * as mcpBundle from './bundle';

import type { ServerBackend, ClientVersion, Root, Server } from './server';
import type { ServerBackend, ClientInfo, Server } from './server';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
Expand All @@ -36,15 +36,15 @@ export class ProxyBackend implements ServerBackend {
private _mcpProviders: MCPProvider[];
private _currentClient: Client | undefined;
private _contextSwitchTool: Tool;
private _roots: Root[] = [];
private _clientInfo: ClientInfo | undefined;

constructor(mcpProviders: MCPProvider[]) {
this._mcpProviders = mcpProviders;
this._contextSwitchTool = this._defineContextSwitchTool();
}

async initialize(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
this._roots = roots;
async initialize(server: Server, clientInfo: ClientInfo): Promise<void> {
this._clientInfo = clientInfo;
}

async listTools(): Promise<Tool[]> {
Expand Down Expand Up @@ -124,7 +124,7 @@ export class ProxyBackend implements ServerBackend {
listRoots: true,
},
});
client.setRequestHandler(mcpBundle.ListRootsRequestSchema, () => ({ roots: this._roots }));
client.setRequestHandler(mcpBundle.ListRootsRequestSchema, () => ({ roots: this._clientInfo?.roots || [] }));
client.setRequestHandler(mcpBundle.PingRequestSchema, () => ({}));

const transport = await factory.connect();
Expand Down
30 changes: 26 additions & 4 deletions packages/playwright/src/mcp/sdk/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import { fileURLToPath } from 'url';

import { debug } from 'playwright-core/lib/utilsBundle';

import * as mcpBundle from './bundle';
Expand All @@ -28,10 +30,15 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';

const serverDebug = debug('pw:mcp:server');

export type ClientVersion = { name: string, version: string };
export type ClientInfo = {
name: string;
version: string;
roots: Root[];
timestamp: number;
};

export interface ServerBackend {
initialize?(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void>;
initialize?(server: Server, clientInfo: ClientInfo): Promise<void>;
listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
serverClosed?(server: Server): void;
Expand Down Expand Up @@ -93,8 +100,15 @@ const initializeServer = async (server: Server, backend: ServerBackend, runHeart
const { roots } = await server.listRoots();
clientRoots = roots;
}
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
await backend.initialize?.(server, clientVersion, clientRoots);

const clientInfo: ClientInfo = {
name: server.getClientVersion()?.name ?? 'unknown',
version: server.getClientVersion()?.version ?? 'unknown',
roots: clientRoots,
timestamp: Date.now(),
};

await backend.initialize?.(server, clientInfo);
if (runHeartbeat)
startHeartbeat(server);
};
Expand Down Expand Up @@ -145,3 +159,11 @@ export async function start(serverBackendFactory: ServerBackendFactory, options:
// eslint-disable-next-line no-console
console.error(message);
}

export function firstRootPath(clientInfo: ClientInfo): string | undefined {
if (clientInfo.roots.length === 0)
return undefined;
const firstRootUri = clientInfo.roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined;
return url ? fileURLToPath(url) : undefined;
}
4 changes: 2 additions & 2 deletions packages/playwright/src/mcp/test/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import { BrowserServerBackend } from '../browser/browserServerBackend';

import type * as playwright from '../../../index';
import type { Page } from '../../../../playwright-core/src/client/page';
import type { BrowserContextFactory, ClientInfo } from '../browser/browserContextFactory';

import type { BrowserContextFactory } from '../browser/browserContextFactory';
import type { ClientInfo } from '../sdk/server';

export async function runBrowserBackendOnError(page: playwright.Page, message: () => string) {
const testInfo = currentTestInfo();
Expand Down
16 changes: 5 additions & 11 deletions packages/playwright/src/mcp/test/testBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
* limitations under the License.
*/

import { fileURLToPath } from 'url';

import * as mcp from '../sdk/exports';
import { TestContext } from './testContext';
import { listTests, runTests, debugTest, setupPage } from './testTools.js';
Expand All @@ -36,20 +34,16 @@ export class TestServerBackend implements mcp.ServerBackend {
this._configOption = configOption;
}

async initialize(server: mcp.Server, clientVersion: mcp.ClientVersion, roots: mcp.Root[]): Promise<void> {
async initialize(server: mcp.Server, clientInfo: mcp.ClientInfo): Promise<void> {
if (this._configOption) {
this._context.setConfigLocation(resolveConfigLocation(this._configOption));
return;
}

if (roots.length > 0) {
const firstRootUri = roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined;
const folder = url ? fileURLToPath(url) : undefined;
if (folder) {
this._context.setConfigLocation(resolveConfigLocation(folder));
return;
}
const rootPath = mcp.firstRootPath(clientInfo);
if (rootPath) {
this._context.setConfigLocation(resolveConfigLocation(rootPath));
return;
}

throw new Error('No config option or MCP root path provided');
Expand Down
Loading
Loading