Files
opencli/src/browser.ts
T
jackwenerandKasumiChen b0c51ddf19 fix: use Playwright MCP --executable-path flag (kebab-case)
Extract buildMcpArgs() helper and fix --executablePath → --executable-path
to match the Playwright MCP CLI's expected flag format.

Closes #16

Co-authored-by: KasumiChen <KasumiChen@users.noreply.github.com>
2026-03-16 12:58:18 +08:00

764 lines
27 KiB
TypeScript

/**
* Browser interaction via Playwright MCP Bridge extension.
* Connects to an existing Chrome browser through the extension.
*/
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { formatSnapshot } from './snapshotFormatter.js';
// Read version from package.json (single source of truth)
const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })();
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
const STDERR_BUFFER_LIMIT = 16 * 1024;
const INITIAL_TABS_TIMEOUT_MS = 1500;
const TAB_CLEANUP_TIMEOUT_MS = 2000;
let _cachedMcpServerPath: string | null | undefined;
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
type ConnectFailureInput = {
kind: ConnectFailureKind;
timeout: number;
hasExtensionToken: boolean;
tokenFingerprint?: string | null;
stderr?: string;
exitCode?: number | null;
rawMessage?: string;
};
export function getTokenFingerprint(token: string | undefined): string | null {
if (!token) return null;
return createHash('sha256').update(token).digest('hex').slice(0, 8);
}
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
const stderr = input.stderr?.trim();
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
if (input.kind === 'missing-token') {
return new Error(
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
suffix,
);
}
if (input.kind === 'extension-not-installed') {
return new Error(
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
'If Chrome shows an approval dialog, click Allow.' +
suffix,
);
}
if (input.kind === 'extension-timeout') {
const likelyCause = input.hasExtensionToken
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
return new Error(
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
`${likelyCause} If a browser prompt is visible, click Allow.` +
suffix,
);
}
if (input.kind === 'mcp-init') {
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
}
if (input.kind === 'process-exit') {
return new Error(
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
suffix,
);
}
return new Error(input.rawMessage ?? 'Failed to connect to browser');
}
function inferConnectFailureKind(args: {
hasExtensionToken: boolean;
stderr: string;
rawMessage?: string;
exited?: boolean;
}): ConnectFailureKind {
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
if (!args.hasExtensionToken)
return 'missing-token';
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
return 'extension-not-installed';
if (args.rawMessage?.startsWith('MCP init failed:'))
return 'mcp-init';
if (args.exited)
return 'process-exit';
return 'extension-timeout';
}
// JSON-RPC helpers
let _nextId = 1;
function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
const id = _nextId++;
return {
id,
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
};
}
import type { IPage } from './types.js';
/**
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
*/
export class Page implements IPage {
constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
async call(method: string, params: Record<string, any> = {}): Promise<any> {
const resp = await this._request(method, params);
if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
// Extract text content from MCP result
const result = resp.result;
if (result?.content) {
const textParts = result.content.filter((c: any) => c.type === 'text');
if (textParts.length === 1) {
let text = textParts[0].text;
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
// Strip the "### Ran Playwright code" suffix to get clean JSON
const codeMarker = text.indexOf('### Ran Playwright code');
if (codeMarker !== -1) {
text = text.slice(0, codeMarker).trim();
}
// Also handle "### Result\n[JSON]" format (some MCP versions)
const resultMarker = text.indexOf('### Result\n');
if (resultMarker !== -1) {
text = text.slice(resultMarker + '### Result\n'.length).trim();
}
try { return JSON.parse(text); } catch { return text; }
}
}
return result;
}
// --- High-level methods ---
async goto(url: string): Promise<void> {
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
}
async evaluate(js: string): Promise<any> {
// Normalize IIFE format to function format expected by MCP browser_evaluate
const normalized = this.normalizeEval(js);
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
}
private normalizeEval(source: string): string {
const s = source.trim();
if (!s) return '() => undefined';
// IIFE: (async () => {...})() → wrap as () => (...)
if (s.startsWith('(') && s.endsWith(')()')) return `() => (${s})`;
// Already a function/arrow
if (/^(async\s+)?\([^)]*\)\s*=>/.test(s)) return s;
if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(s)) return s;
if (s.startsWith('function ') || s.startsWith('async function ')) return s;
// Raw expression → wrap
return `() => (${s})`;
}
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
async click(ref: string): Promise<void> {
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
}
async typeText(ref: string, text: string): Promise<void> {
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
}
async pressKey(key: string): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
}
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
if (typeof options === 'number') {
await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
} else {
// Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
}
}
async tabs(): Promise<any> {
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
}
async closeTab(index?: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
}
async newTab(): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
}
async selectTab(index: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
}
async consoleMessages(level: string = 'info'): Promise<any> {
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
}
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
const times = options.times ?? 3;
const delayMs = options.delayMs ?? 2000;
const js = `
async () => {
const maxTimes = ${times};
const maxWaitMs = ${delayMs};
for (let i = 0; i < maxTimes; i++) {
const lastHeight = document.body.scrollHeight;
window.scrollTo(0, lastHeight);
await new Promise(resolve => {
let timeoutId;
const observer = new MutationObserver(() => {
if (document.body.scrollHeight > lastHeight) {
clearTimeout(timeoutId);
observer.disconnect();
setTimeout(resolve, 100); // Small debounce for rendering
}
});
observer.observe(document.body, { childList: true, subtree: true });
timeoutId = setTimeout(() => {
observer.disconnect();
resolve(null);
}, maxWaitMs);
});
}
}
`;
await this.evaluate(js);
}
async installInterceptor(pattern: string): Promise<void> {
const js = `
() => {
window.__opencli_xhr = window.__opencli_xhr || [];
window.__opencli_patterns = window.__opencli_patterns || [];
if (!window.__opencli_patterns.includes('${pattern}')) {
window.__opencli_patterns.push('${pattern}');
}
if (!window.__patched_xhr) {
const checkMatch = (url) => window.__opencli_patterns.some(p => url.includes(p));
const XHR = XMLHttpRequest.prototype;
const open = XHR.open;
const send = XHR.send;
XHR.open = function(method, url) {
this._url = url;
return open.call(this, method, url, ...Array.prototype.slice.call(arguments, 2));
};
XHR.send = function() {
this.addEventListener('load', function() {
if (checkMatch(this._url)) {
try { window.__opencli_xhr.push({url: this._url, data: JSON.parse(this.responseText)}); } catch(e){}
}
});
return send.apply(this, arguments);
};
const origFetch = window.fetch;
window.fetch = async function(...args) {
let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
const res = await origFetch.apply(this, args);
setTimeout(async () => {
try {
if (checkMatch(u)) {
const clone = res.clone();
const j = await clone.json();
window.__opencli_xhr.push({url: u, data: j});
}
} catch(e) {}
}, 0);
return res;
};
window.__patched_xhr = true;
}
}
`;
await this.evaluate(js);
}
async getInterceptedRequests(): Promise<any[]> {
return (await this.evaluate('() => window.__opencli_xhr')) || [];
}
}
/**
* Playwright MCP process manager.
*/
export class PlaywrightMCP {
private static _activeInsts: Set<PlaywrightMCP> = new Set();
private static _cleanupRegistered = false;
private static _registerGlobalCleanup() {
if (this._cleanupRegistered) return;
this._cleanupRegistered = true;
const cleanup = () => {
for (const inst of this._activeInsts) {
if (inst._proc && !inst._proc.killed) {
try { inst._proc.kill('SIGKILL'); } catch {}
}
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
}
private _proc: ChildProcess | null = null;
private _buffer = '';
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
private _initialTabIdentities: string[] = [];
private _closingPromise: Promise<void> | null = null;
private _state: PlaywrightMCPState = 'idle';
private _page: Page | null = null;
get state(): PlaywrightMCPState {
return this._state;
}
private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
return new Promise<any>((resolve, reject) => {
if (!this._proc?.stdin?.writable) {
reject(new Error('Playwright MCP process is not writable'));
return;
}
const { id, message } = createJsonRpcRequest(method, params);
this._pending.set(id, { resolve, reject });
this._proc.stdin.write(message, (err) => {
if (!err) return;
this._pending.delete(id);
reject(err);
});
});
}
private _rejectPendingRequests(error: Error): void {
const pending = [...this._pending.values()];
this._pending.clear();
for (const waiter of pending) waiter.reject(error);
}
private _resetAfterFailedConnect(): void {
const proc = this._proc;
this._page = null;
this._proc = null;
this._buffer = '';
this._initialTabIdentities = [];
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
PlaywrightMCP._activeInsts.delete(this);
if (proc && !proc.killed) {
try { proc.kill('SIGKILL'); } catch {}
}
}
async connect(opts: { timeout?: number } = {}): Promise<Page> {
if (this._state === 'connected' && this._page) return this._page;
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
const mcpPath = findMcpServerPath();
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
PlaywrightMCP._registerGlobalCleanup();
PlaywrightMCP._activeInsts.add(this);
this._state = 'connecting';
const timeout = opts.timeout ?? CONNECT_TIMEOUT;
return new Promise<Page>((resolve, reject) => {
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
const tokenFingerprint = getTokenFingerprint(extensionToken);
let stderrBuffer = '';
let settled = false;
const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
if (settled) return;
settled = true;
this._state = 'idle';
clearTimeout(timer);
this._resetAfterFailedConnect();
reject(formatBrowserConnectError({
kind,
timeout,
hasExtensionToken: !!extensionToken,
tokenFingerprint,
stderr: stderrBuffer,
exitCode: extra.exitCode,
rawMessage: extra.rawMessage,
}));
};
const settleSuccess = (pageToResolve: Page) => {
if (settled) return;
settled = true;
this._state = 'connected';
clearTimeout(timer);
resolve(pageToResolve);
};
const timer = setTimeout(() => {
debugLog('Connection timed out');
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
}));
}, timeout * 1000);
const mcpArgs = buildMcpArgs({
mcpPath,
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
});
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
}
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
this._proc = spawn('node', mcpArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
// Increase max listeners to avoid warnings
this._proc.setMaxListeners(20);
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
const page = new Page((method, params = {}) => this._sendRequest(method, params));
this._page = page;
this._proc.stdout?.on('data', (chunk: Buffer) => {
this._buffer += chunk.toString();
const lines = this._buffer.split('\n');
this._buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
debugLog(`RECV: ${line}`);
try {
const parsed = JSON.parse(line);
if (typeof parsed?.id === 'number') {
const waiter = this._pending.get(parsed.id);
if (waiter) {
this._pending.delete(parsed.id);
waiter.resolve(parsed);
}
}
} catch (e) {
debugLog(`Parse error: ${e}`);
}
}
});
this._proc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
debugLog(`STDERR: ${text}`);
});
this._proc.on('error', (err) => {
debugLog(`Subprocess error: ${err.message}`);
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
settleError('process-exit', { rawMessage: err.message });
});
this._proc.on('close', (code) => {
debugLog(`Subprocess closed with code ${code}`);
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
if (!settled) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
exited: true,
}), { exitCode: code });
}
});
// Initialize: send initialize request
debugLog('Waiting for initialize response...');
this._sendRequest('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'opencli', version: PKG_VERSION },
}).then((resp) => {
debugLog('Got initialize response');
if (resp.error) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
rawMessage: `MCP init failed: ${resp.error.message}`,
}), { rawMessage: resp.error.message });
return;
}
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
debugLog(`SEND: ${initializedMsg.trim()}`);
this._proc?.stdin?.write(initializedMsg);
// Use tabs as a readiness probe and for tab cleanup bookkeeping.
debugLog('Fetching initial tabs count...');
withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
this._initialTabIdentities = extractTabIdentities(tabs);
settleSuccess(page);
}).catch((err) => {
debugLog(`Tabs fetch error: ${err.message}`);
settleSuccess(page);
});
}).catch((err) => {
debugLog(`Init promise rejected: ${err.message}`);
settleError('mcp-init', { rawMessage: err.message });
});
});
}
async close(): Promise<void> {
if (this._closingPromise) return this._closingPromise;
if (this._state === 'closed') return;
this._state = 'closing';
this._closingPromise = (async () => {
try {
// Extension mode opens bridge/session tabs that we can clean up best-effort.
if (this._page && this._proc && !this._proc.killed) {
try {
const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
const tabEntries = extractTabEntries(tabs);
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
for (const index of tabsToClose) {
try { await this._page.closeTab(index); } catch {}
}
} catch {}
}
if (this._proc && !this._proc.killed) {
this._proc.kill('SIGTERM');
const exited = await new Promise<boolean>((res) => {
let done = false;
const finish = (value: boolean) => {
if (done) return;
done = true;
res(value);
};
this._proc?.once('exit', () => finish(true));
setTimeout(() => finish(false), 3000);
});
if (!exited && this._proc && !this._proc.killed) {
try { this._proc.kill('SIGKILL'); } catch {}
}
}
} finally {
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
this._page = null;
this._proc = null;
this._state = 'closed';
PlaywrightMCP._activeInsts.delete(this);
}
})();
return this._closingPromise;
}
}
function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
if (Array.isArray(raw)) {
return raw.map((tab: any, index: number) => ({
index,
identity: [
tab?.id ?? '',
tab?.url ?? '',
tab?.title ?? '',
tab?.name ?? '',
].join('|'),
}));
}
if (typeof raw === 'string') {
return raw
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
if (mcpMatch) {
return {
index: parseInt(mcpMatch[1], 10),
identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
};
}
// Legacy format: "Tab 0 ..."
const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
if (legacyMatch) {
return {
index: parseInt(legacyMatch[1], 10),
identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
};
}
return null;
})
.filter((entry): entry is { index: number; identity: string } => entry !== null);
}
return [];
}
function extractTabIdentities(raw: any): string[] {
return extractTabEntries(raw).map(tab => tab.identity);
}
function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
const remaining = new Map<string, number>();
for (const identity of initialIdentities) {
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
}
const tabsToClose: number[] = [];
for (const tab of currentTabs) {
const count = remaining.get(tab.identity) ?? 0;
if (count > 0) {
remaining.set(tab.identity, count - 1);
continue;
}
tabsToClose.push(tab.index);
}
return tabsToClose.sort((a, b) => b - a);
}
function appendLimited(current: string, chunk: string, limit: number): string {
const next = current + chunk;
if (next.length <= limit) return next;
return next.slice(-limit);
}
function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
const args = [input.mcpPath, '--extension'];
if (input.executablePath) {
args.push('--executable-path', input.executablePath);
}
return args;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
export const __test__ = {
createJsonRpcRequest,
extractTabEntries,
diffTabIndexes,
appendLimited,
buildMcpArgs,
withTimeout,
};
function findMcpServerPath(): string | null {
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
if (envMcp && fs.existsSync(envMcp)) {
_cachedMcpServerPath = envMcp;
return _cachedMcpServerPath;
}
// Check local node_modules first (@playwright/mcp is the modern package)
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
if (fs.existsSync(localMcp)) {
_cachedMcpServerPath = localMcp;
return _cachedMcpServerPath;
}
// Check project-relative path
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
if (fs.existsSync(projectMcp)) {
_cachedMcpServerPath = projectMcp;
return _cachedMcpServerPath;
}
// Check common locations
const candidates = [
path.join(os.homedir(), '.npm', '_npx'),
path.join(os.homedir(), 'node_modules', '.bin'),
'/usr/local/lib/node_modules',
];
// Try npx resolution (legacy package name)
try {
const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
if (result && fs.existsSync(result)) {
_cachedMcpServerPath = result;
return _cachedMcpServerPath;
}
} catch {}
// Try which
try {
const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
if (result && fs.existsSync(result)) {
_cachedMcpServerPath = result;
return _cachedMcpServerPath;
}
} catch {}
// Search in common npx cache
for (const base of candidates) {
if (!fs.existsSync(base)) continue;
try {
const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
if (found) {
_cachedMcpServerPath = found;
return _cachedMcpServerPath;
}
} catch {}
}
_cachedMcpServerPath = null;
return _cachedMcpServerPath;
}