Skip to content

fix(extension): support multiple tabs opened by Playwright#1478

Closed
snomiao wants to merge 4 commits intomicrosoft:mainfrom
snomiao:fix-multi-tab
Closed

fix(extension): support multiple tabs opened by Playwright#1478
snomiao wants to merge 4 commits intomicrosoft:mainfrom
snomiao:fix-multi-tab

Conversation

@snomiao
Copy link
Copy Markdown
Contributor

@snomiao snomiao commented Mar 20, 2026

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`

  • Add `createTab` command: creates a Chrome tab via `chrome.tabs.create()`, attaches the debugger, and returns `{ tabId, targetInfo }` to the relay
  • Track Playwright-opened tabs in `_playwrightTabIds: Set`
  • Forward CDP events from all Playwright-opened tabs (tagged with `tabId` for relay routing)
  • Route `forwardCDPCommand` to the correct debuggee by `tabId`
  • `attachToTab` now returns `tabId` so the relay can build its session→tab mapping
  • Add `onPlaywrightTabCreated` / `onPlaywrightTabRemoved` callbacks for the background script
  • Detach debugger from all Playwright tabs on close

`background.ts`

  • Replace single `_activeConnection` with `_connections: Map<mcpRelayUrl, ConnectionState>` — each relay URL (UUID-based) gets fully isolated state
  • Multiple simultaneous MCP instances no longer conflict in the extension
  • Track Playwright tab IDs per connection; clear badges and the set when the connection closes
  • Show a blue `✓` badge on Playwright-managed tabs (distinct from the green `✓` on the initial connected tab)
  • Expose `playwrightTabIds` via `getConnectionStatus`
  • Clean up Playwright tab IDs in `_onTabRemoved`

`status.tsx`

  • Show all active connections, each with their own Playwright-managed tab list
  • Add a "Playwright managed tabs" section per connection, clickable to focus

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.

@snomiao
Copy link
Copy Markdown
Contributor Author

snomiao commented Mar 20, 2026

@microsoft-github-policy-service agree

snomiao added 4 commits March 21, 2026 23:56
…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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 createTab command in RelayConnection, and route CDP traffic by tabId.
  • 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.

Comment on lines 121 to +132
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;

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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).

Copilot uses AI. Check for mistakes.
Comment on lines 188 to +194
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);
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +184
// 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 };
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.
}
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +171
// 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');
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +191 to 195
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;
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment on lines +164 to +185
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 };
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@pavelfeldman
Copy link
Copy Markdown
Member

We are not ready to accept it sorry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow the MCP server (with --extension) to manage multiple tabs

3 participants