@jackwener/opencli 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,51 +1,77 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { formatBrowserConnectError, getTokenFingerprint } from './browser.js';
1
+ import { describe, it, expect } from 'vitest';
2
+ import { PlaywrightMCP, __test__ } from './browser.js';
3
3
 
4
- describe('getTokenFingerprint', () => {
5
- it('returns null for empty token', () => {
6
- expect(getTokenFingerprint(undefined)).toBeNull();
4
+ describe('browser helpers', () => {
5
+ it('creates JSON-RPC requests with unique ids', () => {
6
+ const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
7
+ const second = __test__.createJsonRpcRequest('tools/call', { name: 'browser_snapshot' });
8
+
9
+ expect(second.id).toBe(first.id + 1);
10
+ expect(first.message).toContain(`"id":${first.id}`);
11
+ expect(second.message).toContain(`"id":${second.id}`);
12
+ });
13
+
14
+ it('extracts tab entries from string snapshots', () => {
15
+ const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
16
+
17
+ expect(entries).toEqual([
18
+ { index: 0, identity: 'https://example.com' },
19
+ { index: 1, identity: 'Chrome Extension' },
20
+ ]);
7
21
  });
8
22
 
9
- it('returns stable short fingerprint for token', () => {
10
- expect(getTokenFingerprint('abc123')).toBe('6ca13d52');
23
+ it('closes only tabs that were opened during the session', () => {
24
+ const tabsToClose = __test__.diffTabIndexes(
25
+ ['https://example.com', 'Chrome Extension'],
26
+ [
27
+ { index: 0, identity: 'https://example.com' },
28
+ { index: 1, identity: 'Chrome Extension' },
29
+ { index: 2, identity: 'https://target.example/page' },
30
+ { index: 3, identity: 'chrome-extension://bridge' },
31
+ ],
32
+ );
33
+
34
+ expect(tabsToClose).toEqual([3, 2]);
35
+ });
36
+
37
+ it('keeps only the tail of stderr buffers', () => {
38
+ expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
39
+ });
40
+
41
+ it('times out slow promises', async () => {
42
+ await expect(__test__.withTimeout(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
11
43
  });
12
44
  });
13
45
 
14
- describe('formatBrowserConnectError', () => {
15
- it('explains missing extension token clearly', () => {
16
- const err = formatBrowserConnectError({
17
- kind: 'missing-token',
18
- mode: 'extension',
19
- timeout: 30,
20
- hasExtensionToken: false,
21
- });
22
-
23
- expect(err.message).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set');
24
- expect(err.message).toContain('manual approval dialog');
25
- });
26
-
27
- it('mentions token mismatch as likely cause for extension timeout', () => {
28
- const err = formatBrowserConnectError({
29
- kind: 'extension-timeout',
30
- mode: 'extension',
31
- timeout: 30,
32
- hasExtensionToken: true,
33
- tokenFingerprint: 'deadbeef',
34
- });
35
-
36
- expect(err.message).toContain('does not match the token currently shown by the browser extension');
37
- expect(err.message).toContain('deadbeef');
38
- });
39
-
40
- it('keeps CDP timeout guidance separate', () => {
41
- const err = formatBrowserConnectError({
42
- kind: 'cdp-timeout',
43
- mode: 'cdp',
44
- timeout: 30,
45
- hasExtensionToken: false,
46
- });
47
-
48
- expect(err.message).toContain('via CDP');
49
- expect(err.message).toContain('chrome://inspect#remote-debugging');
46
+ describe('PlaywrightMCP state', () => {
47
+ it('transitions to closed after close()', async () => {
48
+ const mcp = new PlaywrightMCP();
49
+
50
+ expect(mcp.state).toBe('idle');
51
+
52
+ await mcp.close();
53
+
54
+ expect(mcp.state).toBe('closed');
55
+ });
56
+
57
+ it('rejects connect() after the session has been closed', async () => {
58
+ const mcp = new PlaywrightMCP();
59
+ await mcp.close();
60
+
61
+ await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
62
+ });
63
+
64
+ it('rejects connect() while already connecting', async () => {
65
+ const mcp = new PlaywrightMCP();
66
+ (mcp as any)._state = 'connecting';
67
+
68
+ await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
69
+ });
70
+
71
+ it('rejects connect() while closing', async () => {
72
+ const mcp = new PlaywrightMCP();
73
+ (mcp as any)._state = 'closing';
74
+
75
+ await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
50
76
  });
51
77
  });
package/src/browser.ts CHANGED
@@ -80,12 +80,13 @@ export async function discoverChromeEndpoint(): Promise<string | null> {
80
80
  const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
81
81
  const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })();
82
82
 
83
- const EXTENSION_LOCK_TIMEOUT = parseInt(process.env.OPENCLI_EXTENSION_LOCK_TIMEOUT ?? '120', 10);
84
- const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INTERVAL ?? '1', 10);
85
83
  const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
86
- const LOCK_DIR = path.join(os.tmpdir(), 'opencli-mcp-lock');
84
+ const STDERR_BUFFER_LIMIT = 16 * 1024;
85
+ const TAB_CLEANUP_TIMEOUT_MS = 2000;
86
+ let _cachedMcpServerPath: string | null | undefined;
87
87
 
88
88
  type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
89
+ type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
89
90
 
90
91
  type ConnectFailureInput = {
91
92
  kind: ConnectFailureKind;
@@ -187,8 +188,12 @@ function inferConnectFailureKind(args: {
187
188
 
188
189
  // JSON-RPC helpers
189
190
  let _nextId = 1;
190
- function jsonRpcRequest(method: string, params: Record<string, any> = {}): string {
191
- return JSON.stringify({ jsonrpc: '2.0', id: _nextId++, method, params }) + '\n';
191
+ function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
192
+ const id = _nextId++;
193
+ return {
194
+ id,
195
+ message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
196
+ };
192
197
  }
193
198
 
194
199
  import type { IPage } from './types.js';
@@ -197,11 +202,10 @@ import type { IPage } from './types.js';
197
202
  * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
198
203
  */
199
204
  export class Page implements IPage {
200
- constructor(private _send: (msg: string) => void, private _recv: () => Promise<any>) {}
205
+ constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
201
206
 
202
207
  async call(method: string, params: Record<string, any> = {}): Promise<any> {
203
- this._send(jsonRpcRequest(method, params));
204
- const resp = await this._recv();
208
+ const resp = await this._request(method, params);
205
209
  if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
206
210
  // Extract text content from MCP result
207
211
  const result = resp.result;
@@ -405,10 +409,6 @@ export class PlaywrightMCP {
405
409
  this._cleanupRegistered = true;
406
410
  const cleanup = () => {
407
411
  for (const inst of this._activeInsts) {
408
- if (inst._lockAcquired) {
409
- try { fs.rmdirSync(LOCK_DIR); } catch {}
410
- inst._lockAcquired = false;
411
- }
412
412
  if (inst._proc && !inst._proc.killed) {
413
413
  try { inst._proc.kill('SIGKILL'); } catch {}
414
414
  }
@@ -421,18 +421,66 @@ export class PlaywrightMCP {
421
421
 
422
422
  private _proc: ChildProcess | null = null;
423
423
  private _buffer = '';
424
- private _waiters: Array<(data: any) => void> = [];
425
- private _lockAcquired = false;
426
- private _initialTabCount = 0;
424
+ private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
425
+ private _initialTabIdentities: string[] = [];
426
+ private _closingPromise: Promise<void> | null = null;
427
+ private _state: PlaywrightMCPState = 'idle';
427
428
 
428
429
  private _page: Page | null = null;
429
430
 
431
+ get state(): PlaywrightMCPState {
432
+ return this._state;
433
+ }
434
+
435
+ private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
436
+ return new Promise<any>((resolve, reject) => {
437
+ if (!this._proc?.stdin?.writable) {
438
+ reject(new Error('Playwright MCP process is not writable'));
439
+ return;
440
+ }
441
+ const { id, message } = createJsonRpcRequest(method, params);
442
+ this._pending.set(id, { resolve, reject });
443
+ this._proc.stdin.write(message, (err) => {
444
+ if (!err) return;
445
+ this._pending.delete(id);
446
+ reject(err);
447
+ });
448
+ });
449
+ }
450
+
451
+ private _rejectPendingRequests(error: Error): void {
452
+ const pending = [...this._pending.values()];
453
+ this._pending.clear();
454
+ for (const waiter of pending) waiter.reject(error);
455
+ }
456
+
457
+ private _resetAfterFailedConnect(): void {
458
+ const proc = this._proc;
459
+ this._page = null;
460
+ this._proc = null;
461
+ this._buffer = '';
462
+ this._initialTabIdentities = [];
463
+ this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
464
+ PlaywrightMCP._activeInsts.delete(this);
465
+ if (proc && !proc.killed) {
466
+ try { proc.kill('SIGKILL'); } catch {}
467
+ }
468
+ }
469
+
430
470
  async connect(opts: { timeout?: number; forceExtension?: boolean } = {}): Promise<Page> {
431
- await this._acquireLock();
432
- const timeout = opts.timeout ?? CONNECT_TIMEOUT;
471
+ if (this._state === 'connected' && this._page) return this._page;
472
+ if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
473
+ if (this._state === 'closing') throw new Error('Playwright MCP is closing');
474
+ if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
475
+
433
476
  const mcpPath = findMcpServerPath();
434
477
  if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
435
478
 
479
+ PlaywrightMCP._registerGlobalCleanup();
480
+ PlaywrightMCP._activeInsts.add(this);
481
+ this._state = 'connecting';
482
+ const timeout = opts.timeout ?? CONNECT_TIMEOUT;
483
+
436
484
  // Connection priority:
437
485
  // 1. OPENCLI_CDP_ENDPOINT env var → explicit CDP endpoint
438
486
  // 2. OPENCLI_USE_CDP=1 → auto-discover via DevToolsActivePort
@@ -460,7 +508,9 @@ export class PlaywrightMCP {
460
508
  const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
461
509
  if (settled) return;
462
510
  settled = true;
511
+ this._state = 'idle';
463
512
  clearTimeout(timer);
513
+ this._resetAfterFailedConnect();
464
514
  reject(formatBrowserConnectError({
465
515
  kind,
466
516
  mode,
@@ -476,6 +526,7 @@ export class PlaywrightMCP {
476
526
  const settleSuccess = (pageToResolve: Page) => {
477
527
  if (settled) return;
478
528
  settled = true;
529
+ this._state = 'connected';
479
530
  clearTimeout(timer);
480
531
  resolve(pageToResolve);
481
532
  };
@@ -515,10 +566,7 @@ export class PlaywrightMCP {
515
566
  this._proc.setMaxListeners(20);
516
567
  if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
517
568
 
518
- const page = new Page(
519
- (msg) => { if (this._proc?.stdin?.writable) this._proc.stdin.write(msg); },
520
- () => new Promise<any>((res) => { this._waiters.push(res); }),
521
- );
569
+ const page = new Page((method, params = {}) => this._sendRequest(method, params));
522
570
  this._page = page;
523
571
 
524
572
  this._proc.stdout?.on('data', (chunk: Buffer) => {
@@ -530,8 +578,13 @@ export class PlaywrightMCP {
530
578
  debugLog(`RECV: ${line}`);
531
579
  try {
532
580
  const parsed = JSON.parse(line);
533
- const waiter = this._waiters.shift();
534
- if (waiter) waiter(parsed);
581
+ if (typeof parsed?.id === 'number') {
582
+ const waiter = this._pending.get(parsed.id);
583
+ if (waiter) {
584
+ this._pending.delete(parsed.id);
585
+ waiter.resolve(parsed);
586
+ }
587
+ }
535
588
  } catch (e) {
536
589
  debugLog(`Parse error: ${e}`);
537
590
  }
@@ -540,15 +593,17 @@ export class PlaywrightMCP {
540
593
 
541
594
  this._proc.stderr?.on('data', (chunk: Buffer) => {
542
595
  const text = chunk.toString();
543
- stderrBuffer += text;
596
+ stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
544
597
  debugLog(`STDERR: ${text}`);
545
598
  });
546
599
  this._proc.on('error', (err) => {
547
600
  debugLog(`Subprocess error: ${err.message}`);
601
+ this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
548
602
  settleError('process-exit', { rawMessage: err.message });
549
603
  });
550
604
  this._proc.on('close', (code) => {
551
605
  debugLog(`Subprocess closed with code ${code}`);
606
+ this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
552
607
  if (!settled) {
553
608
  settleError(inferConnectFailureKind({
554
609
  mode,
@@ -560,18 +615,12 @@ export class PlaywrightMCP {
560
615
  });
561
616
 
562
617
  // Initialize: send initialize request
563
- const initMsg = jsonRpcRequest('initialize', {
618
+ debugLog('Waiting for initialize response...');
619
+ this._sendRequest('initialize', {
564
620
  protocolVersion: '2024-11-05',
565
621
  capabilities: {},
566
622
  clientInfo: { name: 'opencli', version: PKG_VERSION },
567
- });
568
- debugLog(`SEND: ${initMsg.trim()}`);
569
- this._proc.stdin?.write(initMsg);
570
-
571
- // Wait for initialize response, then send initialized notification
572
- const origRecv = () => new Promise<any>((res) => { this._waiters.push(res); });
573
- debugLog('Waiting for initialize response...');
574
- origRecv().then((resp) => {
623
+ }).then((resp) => {
575
624
  debugLog('Got initialize response');
576
625
  if (resp.error) {
577
626
  settleError(inferConnectFailureKind({
@@ -591,11 +640,7 @@ export class PlaywrightMCP {
591
640
  debugLog('Fetching initial tabs count...');
592
641
  page.tabs().then((tabs: any) => {
593
642
  debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
594
- if (typeof tabs === 'string') {
595
- this._initialTabCount = (tabs.match(/Tab \d+/g) || []).length;
596
- } else if (Array.isArray(tabs)) {
597
- this._initialTabCount = tabs.length;
598
- }
643
+ this._initialTabIdentities = extractTabIdentities(tabs);
599
644
  settleSuccess(page);
600
645
  }).catch((err) => {
601
646
  debugLog(`Tabs fetch error: ${err.message}`);
@@ -609,68 +654,159 @@ export class PlaywrightMCP {
609
654
  }
610
655
 
611
656
  async close(): Promise<void> {
612
- try {
613
- // Close tabs opened during this session (site tabs + extension tabs)
614
- if (this._page && this._proc && !this._proc.killed) {
615
- try {
616
- const tabs = await this._page.tabs();
617
- const tabStr = typeof tabs === 'string' ? tabs : JSON.stringify(tabs);
618
- const allTabs = tabStr.match(/Tab (\d+)/g) || [];
619
- const currentTabCount = allTabs.length;
620
-
621
- // Close tabs in reverse order to avoid index shifting issues
622
- // Keep the original tabs that existed before the command started
623
- if (currentTabCount > this._initialTabCount && this._initialTabCount > 0) {
624
- for (let i = currentTabCount - 1; i >= this._initialTabCount; i--) {
625
- try { await this._page.closeTab(i); } catch {}
657
+ if (this._closingPromise) return this._closingPromise;
658
+ if (this._state === 'closed') return;
659
+ this._state = 'closing';
660
+ this._closingPromise = (async () => {
661
+ try {
662
+ // Close tabs opened during this session (site tabs + extension tabs)
663
+ if (this._page && this._proc && !this._proc.killed) {
664
+ try {
665
+ const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
666
+ const tabEntries = extractTabEntries(tabs);
667
+ const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
668
+ for (const index of tabsToClose) {
669
+ try { await this._page.closeTab(index); } catch {}
626
670
  }
671
+ } catch {}
672
+ }
673
+ if (this._proc && !this._proc.killed) {
674
+ this._proc.kill('SIGTERM');
675
+ const exited = await new Promise<boolean>((res) => {
676
+ let done = false;
677
+ const finish = (value: boolean) => {
678
+ if (done) return;
679
+ done = true;
680
+ res(value);
681
+ };
682
+ this._proc?.once('exit', () => finish(true));
683
+ setTimeout(() => finish(false), 3000);
684
+ });
685
+ if (!exited && this._proc && !this._proc.killed) {
686
+ try { this._proc.kill('SIGKILL'); } catch {}
627
687
  }
628
- } catch {}
629
- }
630
- if (this._proc && !this._proc.killed) {
631
- this._proc.kill('SIGTERM');
632
- await new Promise<void>((res) => { this._proc?.on('exit', () => res()); setTimeout(res, 3000); });
688
+ }
689
+ } finally {
690
+ this._rejectPendingRequests(new Error('Playwright MCP session closed'));
691
+ this._page = null;
692
+ this._proc = null;
693
+ this._state = 'closed';
694
+ PlaywrightMCP._activeInsts.delete(this);
633
695
  }
634
- } finally {
635
- this._page = null;
636
- this._releaseLock();
637
- PlaywrightMCP._activeInsts.delete(this);
638
- }
696
+ })();
697
+ return this._closingPromise;
639
698
  }
699
+ }
640
700
 
641
- private async _acquireLock(): Promise<void> {
642
- const start = Date.now();
643
- while (true) {
644
- try { fs.mkdirSync(LOCK_DIR, { recursive: false }); this._lockAcquired = true; return; }
645
- catch (e: any) {
646
- if (e.code !== 'EEXIST') throw e;
647
- if ((Date.now() - start) / 1000 > EXTENSION_LOCK_TIMEOUT) {
648
- // Force remove stale lock
649
- try { fs.rmdirSync(LOCK_DIR); } catch {}
650
- continue;
651
- }
652
- await new Promise(r => setTimeout(r, EXTENSION_LOCK_POLL * 1000));
653
- }
654
- }
701
+ function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
702
+ if (Array.isArray(raw)) {
703
+ return raw.map((tab: any, index: number) => ({
704
+ index,
705
+ identity: [
706
+ tab?.id ?? '',
707
+ tab?.url ?? '',
708
+ tab?.title ?? '',
709
+ tab?.name ?? '',
710
+ ].join('|'),
711
+ }));
712
+ }
713
+
714
+ if (typeof raw === 'string') {
715
+ return raw
716
+ .split('\n')
717
+ .map(line => line.trim())
718
+ .filter(Boolean)
719
+ .map(line => {
720
+ const match = line.match(/Tab\s+(\d+)\s*(.*)$/);
721
+ if (!match) return null;
722
+ return {
723
+ index: parseInt(match[1], 10),
724
+ identity: match[2].trim() || `tab-${match[1]}`,
725
+ };
726
+ })
727
+ .filter((entry): entry is { index: number; identity: string } => entry !== null);
728
+ }
729
+
730
+ return [];
731
+ }
732
+
733
+ function extractTabIdentities(raw: any): string[] {
734
+ return extractTabEntries(raw).map(tab => tab.identity);
735
+ }
736
+
737
+ function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
738
+ if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
739
+ const remaining = new Map<string, number>();
740
+ for (const identity of initialIdentities) {
741
+ remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
655
742
  }
656
743
 
657
- private _releaseLock(): void {
658
- if (this._lockAcquired) {
659
- try { fs.rmdirSync(LOCK_DIR); } catch {}
660
- this._lockAcquired = false;
744
+ const tabsToClose: number[] = [];
745
+ for (const tab of currentTabs) {
746
+ const count = remaining.get(tab.identity) ?? 0;
747
+ if (count > 0) {
748
+ remaining.set(tab.identity, count - 1);
749
+ continue;
661
750
  }
751
+ tabsToClose.push(tab.index);
662
752
  }
753
+
754
+ return tabsToClose.sort((a, b) => b - a);
755
+ }
756
+
757
+ function appendLimited(current: string, chunk: string, limit: number): string {
758
+ const next = current + chunk;
759
+ if (next.length <= limit) return next;
760
+ return next.slice(-limit);
761
+ }
762
+
763
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
764
+ return new Promise<T>((resolve, reject) => {
765
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
766
+ promise.then(
767
+ (value) => {
768
+ clearTimeout(timer);
769
+ resolve(value);
770
+ },
771
+ (error) => {
772
+ clearTimeout(timer);
773
+ reject(error);
774
+ },
775
+ );
776
+ });
663
777
  }
664
778
 
779
+ export const __test__ = {
780
+ createJsonRpcRequest,
781
+ extractTabEntries,
782
+ diffTabIndexes,
783
+ appendLimited,
784
+ withTimeout,
785
+ };
786
+
665
787
  function findMcpServerPath(): string | null {
788
+ if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
789
+
790
+ const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
791
+ if (envMcp && fs.existsSync(envMcp)) {
792
+ _cachedMcpServerPath = envMcp;
793
+ return _cachedMcpServerPath;
794
+ }
795
+
666
796
  // Check local node_modules first (@playwright/mcp is the modern package)
667
797
  const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
668
- if (fs.existsSync(localMcp)) return localMcp;
798
+ if (fs.existsSync(localMcp)) {
799
+ _cachedMcpServerPath = localMcp;
800
+ return _cachedMcpServerPath;
801
+ }
669
802
 
670
803
  // Check project-relative path
671
804
  const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
672
805
  const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
673
- if (fs.existsSync(projectMcp)) return projectMcp;
806
+ if (fs.existsSync(projectMcp)) {
807
+ _cachedMcpServerPath = projectMcp;
808
+ return _cachedMcpServerPath;
809
+ }
674
810
 
675
811
  // Check common locations
676
812
  const candidates = [
@@ -682,13 +818,19 @@ function findMcpServerPath(): string | null {
682
818
  // Try npx resolution (legacy package name)
683
819
  try {
684
820
  const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
685
- if (result && fs.existsSync(result)) return result;
821
+ if (result && fs.existsSync(result)) {
822
+ _cachedMcpServerPath = result;
823
+ return _cachedMcpServerPath;
824
+ }
686
825
  } catch {}
687
826
 
688
827
  // Try which
689
828
  try {
690
829
  const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
691
- if (result && fs.existsSync(result)) return result;
830
+ if (result && fs.existsSync(result)) {
831
+ _cachedMcpServerPath = result;
832
+ return _cachedMcpServerPath;
833
+ }
692
834
  } catch {}
693
835
 
694
836
  // Search in common npx cache
@@ -696,9 +838,13 @@ function findMcpServerPath(): string | null {
696
838
  if (!fs.existsSync(base)) continue;
697
839
  try {
698
840
  const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
699
- if (found) return found;
841
+ if (found) {
842
+ _cachedMcpServerPath = found;
843
+ return _cachedMcpServerPath;
844
+ }
700
845
  } catch {}
701
846
  }
702
847
 
703
- return null;
848
+ _cachedMcpServerPath = null;
849
+ return _cachedMcpServerPath;
704
850
  }