fix(extension): support multiple tabs opened by Playwright#1478
fix(extension): support multiple tabs opened by Playwright#1478snomiao wants to merge 4 commits intomicrosoft:mainfrom
Conversation
|
@microsoft-github-policy-service agree |
…multiple tabs - Add createTab command: extension creates real Chrome tab via chrome.tabs.create() - Forward CDP events from all Playwright-opened tabs (not just the initial tab) - Route forwardCDPCommand by tabId to correct debuggee - attachToTab now returns tabId for session tracking - Update manifest name to 'Playwright MCP Bridge (multi-tab)' and version to 0.0.68.1
- status.html now shows a 'Playwright managed tabs' section listing all tabs opened by Playwright - Extension icon shows blue ✓ badge on Playwright-managed tabs - background.ts tracks playwright tab IDs via relay callbacks - relayConnection.ts fires onPlaywrightTabCreated/onPlaywrightTabRemoved callbacks - Bump version to 0.0.68.2
- Use inferred Set type: Set<number> = new Set() -> = new Set<number>() - Use nullish coalescing for url default (|| -> ??) - Fix ternary indentation to 4-space continuation - Remove stale comment on forwardCDPCommand - Fix double space in import
…olated - background.ts: replace single _activeConnection with _connections Map keyed by relay URL - Each relay URL (UUID-based) gets its own ConnectionState with its own tab set - Multiple simultaneous MCP instances no longer conflict - status.html shows all active connections, each with their own tab lists - disconnect accepts optional mcpRelayUrl to target specific instance - getConnectionStatus returns full connections array (with legacy compat fields) - Bump version to 0.0.68.3
There was a problem hiding this comment.
Pull request overview
This PR updates the Playwright MCP Bridge extension so that (1) Playwright-opened tabs are debuggable/controllable in --extension mode, and (2) multiple concurrent MCP relay URLs can coexist without clobbering each other’s state in the extension.
Changes:
- Add Playwright-tab tracking + a
createTabcommand inRelayConnection, and route CDP traffic bytabId. - Refactor the background script to manage multiple isolated connections keyed by
mcpRelayUrl, including per-connection tab/badge state. - Update the status UI to display multiple active connections and their Playwright-managed tabs.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| packages/extension/src/relayConnection.ts | Adds multi-tab support (create/attach/route/forward) and Playwright tab lifecycle tracking. |
| packages/extension/src/background.ts | Moves from single active connection to per-mcpRelayUrl connection state, plus badge and cleanup logic. |
| packages/extension/src/ui/status.tsx | Shows all active connections and lists Playwright-managed tabs per connection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> { | ||
| try { | ||
| debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`); | ||
| try { | ||
| this._activeConnection?.close('Another connection is requested'); | ||
| } catch (error: any) { | ||
| debugLog(`Error closing active connection:`, error); | ||
| } | ||
| await this._setConnectedTabId(null); | ||
|
|
||
| this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection; | ||
| if (!this._activeConnection) | ||
| const pending = this._pendingTabSelection.get(selectorTabId); | ||
| if (!pending) | ||
| throw new Error('No active MCP relay connection'); | ||
| this._pendingTabSelection.delete(selectorTabId); | ||
|
|
||
| this._activeConnection.setTabId(tabId); | ||
| this._activeConnection.onclose = () => { | ||
| const connection = pending.connection; | ||
| const relayUrl = pending.mcpRelayUrl; | ||
|
|
There was a problem hiding this comment.
_connectTab logs the mcpRelayUrl argument, but the actual relay URL used for the connection is taken from pending.mcpRelayUrl (relayUrl). If these ever diverge, the log becomes misleading. Consider logging relayUrl (and optionally asserting it matches the message’s mcpRelayUrl).
| if (message.method === 'forwardCDPCommand') { | ||
| const { sessionId, method, params } = message.params; | ||
| debugLog('CDP command:', method, params); | ||
| const debuggerSession: chrome.debugger.DebuggerSession = { | ||
| ...this._debuggee, | ||
| sessionId, | ||
| }; | ||
| // Forward CDP command to chrome.debugger | ||
| return await chrome.debugger.sendCommand( | ||
| debuggerSession, | ||
| method, | ||
| params | ||
| ); | ||
| const { sessionId, method, params, tabId } = message.params; | ||
| debugLog('CDP command:', method, params, 'tabId:', tabId); | ||
| const debuggee: chrome.debugger.DebuggerSession = tabId !== undefined | ||
| ? { tabId, sessionId } | ||
| : { ...this._debuggee, sessionId }; | ||
| return await chrome.debugger.sendCommand(debuggee, method, params); |
There was a problem hiding this comment.
forwardCDPCommand accepts an arbitrary tabId from the relay and forwards the command to that tab without validating it belongs to this RelayConnection (initial tab or a tab in _playwrightTabIds). This allows cross-connection command injection (and potentially data access) if multiple relay connections are active. Reject/ignore tabIds that are not the current connection’s initial tab and not in _playwrightTabIds.
| // Wait briefly for the tab to load enough for the debugger to attach | ||
| await new Promise(resolve => setTimeout(resolve, 300)); | ||
| await chrome.debugger.attach({ tabId }, '1.3'); | ||
| const result: any = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo'); | ||
| const targetInfo = result?.targetInfo || { | ||
| targetId: String(tabId), | ||
| type: 'page', | ||
| title: '', | ||
| url: tab.url || url, | ||
| attached: false, | ||
| canAccessOpener: false, | ||
| }; | ||
| this._playwrightTabIds.add(tabId); | ||
| this.onPlaywrightTabCreated?.(tabId); | ||
| debugLog('Created playwright tab:', tabId, targetInfo); | ||
| return { tabId, targetInfo }; |
There was a problem hiding this comment.
createTab can leave a newly created tab open (and possibly a partially-attached debugger) if chrome.debugger.attach / Target.getTargetInfo fails. Consider wrapping the attach+query in try/catch/finally and cleaning up (detach if needed, and close the tab if creation was initiated by the relay) when an error occurs.
| // Wait briefly for the tab to load enough for the debugger to attach | |
| await new Promise(resolve => setTimeout(resolve, 300)); | |
| await chrome.debugger.attach({ tabId }, '1.3'); | |
| const result: any = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo'); | |
| const targetInfo = result?.targetInfo || { | |
| targetId: String(tabId), | |
| type: 'page', | |
| title: '', | |
| url: tab.url || url, | |
| attached: false, | |
| canAccessOpener: false, | |
| }; | |
| this._playwrightTabIds.add(tabId); | |
| this.onPlaywrightTabCreated?.(tabId); | |
| debugLog('Created playwright tab:', tabId, targetInfo); | |
| return { tabId, targetInfo }; | |
| let attached = false; | |
| let success = false; | |
| try { | |
| // Wait briefly for the tab to load enough for the debugger to attach | |
| await new Promise(resolve => setTimeout(resolve, 300)); | |
| await chrome.debugger.attach({ tabId }, '1.3'); | |
| attached = true; | |
| const result: any = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo'); | |
| const targetInfo = result?.targetInfo || { | |
| targetId: String(tabId), | |
| type: 'page', | |
| title: '', | |
| url: tab.url || url, | |
| attached: false, | |
| canAccessOpener: false, | |
| }; | |
| this._playwrightTabIds.add(tabId); | |
| this.onPlaywrightTabCreated?.(tabId); | |
| debugLog('Created playwright tab:', tabId, targetInfo); | |
| success = true; | |
| return { tabId, targetInfo }; | |
| } catch (error) { | |
| debugLog('Failed to create and attach playwright tab:', error); | |
| throw error; | |
| } finally { | |
| if (!success) { | |
| if (attached) { | |
| try { | |
| await chrome.debugger.detach({ tabId }); | |
| } catch { | |
| // Best-effort detach; ignore errors. | |
| } | |
| } | |
| try { | |
| await chrome.tabs.remove(tabId); | |
| } catch { | |
| // Best-effort close; ignore errors. | |
| } | |
| } | |
| } |
| // Wait briefly for the tab to load enough for the debugger to attach | ||
| await new Promise(resolve => setTimeout(resolve, 300)); | ||
| await chrome.debugger.attach({ tabId }, '1.3'); |
There was a problem hiding this comment.
The fixed setTimeout(..., 300) delay before attaching the debugger is timing-dependent and can be flaky across machines/browser states. Prefer waiting for a deterministic signal (e.g. chrome.tabs.onUpdated to status === 'complete', or polling chrome.tabs.get until the tab is in an attachable state) and/or retrying chrome.debugger.attach with a short backoff.
| const pendingConnection = [...this._pendingTabSelection.entries()].find(([k]) => k === tabId)?.[1]; | ||
| if (pendingConnection) { | ||
| this._pendingTabSelection.delete(tabId); | ||
| pendingConnection.close('Browser tab closed'); | ||
| pendingConnection.connection.close('Browser tab closed'); | ||
| return; |
There was a problem hiding this comment.
_onTabRemoved looks up _pendingTabSelection via [...entries()].find(...), but this is a Map keyed by selectorTabId so this._pendingTabSelection.get(tabId) is the correct and simpler lookup. The current code allocates an array and does a linear scan on every tab close.
| if (message.method === 'createTab') { | ||
| const url = message.params?.url ?? 'about:blank'; | ||
| debugLog('Creating new tab:', url); | ||
| const tab = await chrome.tabs.create({ url, active: true }); | ||
| const tabId = tab.id!; | ||
| // Wait briefly for the tab to load enough for the debugger to attach | ||
| await new Promise(resolve => setTimeout(resolve, 300)); | ||
| await chrome.debugger.attach({ tabId }, '1.3'); | ||
| const result: any = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo'); | ||
| const targetInfo = result?.targetInfo || { | ||
| targetId: String(tabId), | ||
| type: 'page', | ||
| title: '', | ||
| url: tab.url || url, | ||
| attached: false, | ||
| canAccessOpener: false, | ||
| }; | ||
| this._playwrightTabIds.add(tabId); | ||
| this.onPlaywrightTabCreated?.(tabId); | ||
| debugLog('Created playwright tab:', tabId, targetInfo); | ||
| return { tabId, targetInfo }; | ||
| } |
There was a problem hiding this comment.
New multi-tab + multi-connection behavior (tracking playwrightTabIds, routing forwardCDPCommand by tabId, and the createTab command) is not covered by existing extension e2e tests. Add a test that opens at least one additional Playwright-created tab (via Target.createTarget / browser.newPage() depending on the server side) and asserts it is controllable, and (if feasible) a test that two clients can connect concurrently without interfering.
|
We are not ready to accept it sorry. |
Fixes #1317
Problem
When using `--extension` mode, Playwright can only control the single tab that was manually selected in the extension popup. Any tab Playwright opens via `tab-new` (i.e. `Target.createTarget`) is created but immediately uncontrollable.
Root cause: `_onDebuggerEvent` in `RelayConnection` only forwards CDP events where `source.tabId === this._debuggee.tabId`. New tabs never have a debugger attached and their events are never forwarded — so Playwright sees them in `tab-list` but cannot interact with them.
Fix
`relayConnection.ts`
`background.ts`
`status.tsx`
Multi-client architecture
With `--port` (HTTP mode), a single playwright-mcp process handles multiple clients via HTTP Streamable (`POST /mcp`) or SSE (`GET /sse`). Each MCP session calls `createExtensionBrowser()`, which creates a new `CDPRelayServer` with a unique UUID relay URL.
Before this PR, the extension only tracked one relay connection — the second client would be rejected. The multi-instance fix in `background.ts` makes the extension handle each relay URL independently:
```
playwright-mcp --extension --port 4321 (one shared process)
├── Claude session A → CDPRelayServer (uuid-aaa) → extension ConnectionState A → Tab 1
└── Claude session B → CDPRelayServer (uuid-bbb) → extension ConnectionState B → Tab 2
```
Each session needs its own browser tab (Chrome only allows one debugger per tab). As long as each client connects to a different tab, they are fully isolated with no conflicts.
The companion relay-server changes (protocol + `cdpRelay.ts`) are in microsoft/playwright#39805.