feat(extension): support multi-tab - Playwright can open and control multiple tabs#39805
feat(extension): support multi-tab - Playwright can open and control multiple tabs#39805snomiao wants to merge 4 commits intomicrosoft:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds multi-tab support to the MCP extension bridge by introducing a tab-creation command and routing CDP traffic/events to the correct tab across multiple concurrent tab sessions.
Changes:
- Bump MCP extension protocol version and add
createTab, plustabIdrouting metadata for CDP commands/events. - Replace single-tab tracking with per-tab session maps and implement
Target.createTarget/Target.getTargetshandling in the relay. - Route extension → Playwright CDP events back to the correct relay session using
tabId.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/playwright-core/src/tools/mcp/protocol.ts | Protocol v2: adds createTab and tabId fields for routing CDP commands/events per tab. |
| packages/playwright-core/src/tools/mcp/cdpRelay.ts | Implements multi-tab session tracking, handles target creation/listing, and routes messages/events by tab. |
| case 'Target.createTarget': { | ||
| const { targetInfo, tabId } = await this._extensionConnection!.send('createTab', { url: params?.url }); | ||
| const tabSessionId = `pw-tab-${this._nextSessionId++}`; | ||
| this._tabSessions.set(tabSessionId, { targetInfo, tabId }); | ||
| if (tabId !== undefined) | ||
| this._tabIdToSessionId.set(tabId, tabSessionId); | ||
| this._sendToPlaywright({ | ||
| method: 'Target.attachedToTarget', | ||
| params: { | ||
| sessionId: tabSessionId, | ||
| targetInfo: { ...targetInfo, attached: true }, | ||
| waitingForDebugger: false, | ||
| } | ||
| }); | ||
| return { targetId: targetInfo.targetId }; | ||
| } | ||
| case 'Target.getTargets': { | ||
| return { | ||
| targetInfos: [...this._tabSessions.values()].map(s => ({ ...s.targetInfo, attached: true })), | ||
| }; | ||
| } |
There was a problem hiding this comment.
_tabSessions/_tabIdToSessionId entries are only ever added and never removed when tabs are closed or detached, so Target.getTargets can return stale targets and tabId reuse could route events/commands to the wrong session; consider deleting the mappings when handling Target.closeTarget and/or when receiving Target.detachedFromTarget/Target.targetDestroyed for a tab.
There was a problem hiding this comment.
Fixed in 421d507 — added Target.closeTarget handler, Target.targetDestroyed cleanup, and helper methods _removeTabSession/_findSessionByTargetId for bidirectional map cleanup.
|
Addressed the review comment about tab session cleanup in 421d507:
Also rebased onto main to resolve the stale diff with the annotate feature. |
…multiple tabs - Add createTab command to protocol: relay calls it when Playwright opens a new tab via Target.createTarget; extension creates a real Chrome tab and attaches the debugger - Add tabId to forwardCDPCommand/forwardCDPEvent: routes CDP traffic to the correct tab - cdpRelay: replace single _connectedTabInfo with _tabSessions map (sessionId → tabId) and _tabIdToSessionId map for reverse lookup from CDP events - Handle Target.createTarget: call createTab on extension, emit Target.attachedToTarget - Handle Target.getTargets: return all tracked tabs - Bump protocol VERSION to 2
Remove tab session entries from _tabSessions and _tabIdToSessionId when tabs are closed via Target.closeTarget or destroyed via Target.targetDestroyed events to prevent stale targets and tabId reuse.
… extension - Add targetId to synthetic Target.detachedFromTarget so Playwright's CRBrowser._onDetachedFromTarget can look up the page correctly - Forward Target.closeTarget to the extension so the actual browser tab is removed, keeping browser UI in sync with Playwright state
Reuse _forwardToExtension helper for consistent tabId resolution instead of manually extracting tabId. Also await the close to ensure the tab is actually closed before emitting the detach event.
Problem
When using `--extension` mode, `CDPRelayServer` only tracked a single tab via `_connectedTabInfo`. Opening a new page via `browser.newPage()` sent `Target.createTarget` to the relay, which forwarded it as a raw CDP command — the extension had no mechanism to create a real Chrome tab, so the call failed silently.
Fix
`protocol.ts`
`cdpRelay.ts`
How it works
```
Playwright CDPRelayServer Extension
| | |
|-- Target.setAutoAttach ------>| |
| |-- attachToTab --------->|
| |<-- { tabId, targetInfo }|
|<-- Target.attachedToTarget ---| |
| | |
|-- Target.createTarget ------->| |
| |-- createTab(url) ------>|
| | (chrome.tabs.create) |
| |<-- { tabId, targetInfo }|
|<-- Target.attachedToTarget ---| |
| | |
|-- CDP cmd (sessionId=pw-tab-2)| |
| |-- forwardCDPCommand -->|
| | (tabId=456) |
| | (routes to Tab 2) |
```
Multi-client with HTTP Streamable (`/mcp`)
When running `playwright-mcp --extension --port N`, each `POST /mcp` session calls `createExtensionBrowser()` which creates a new `CDPRelayServer` with a unique UUID relay URL. The extension's multi-instance fix (microsoft/playwright-mcp#1478) isolates each relay connection 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
```
Two simultaneous Claude sessions can each control their own tabs with no conflicts, as long as each session is connected to a different browser tab (Chrome only allows one debugger per tab).
Related