Skip to content

Commit c209a55

Browse files
authored
Revert "feat(mcp): support multiple tabs in extension cdpRelay (#40104)" (#40171)
1 parent 1f00974 commit c209a55

2 files changed

Lines changed: 39 additions & 233 deletions

File tree

packages/playwright-core/src/tools/mcp/cdpRelay.ts

Lines changed: 37 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,7 @@
1919
*
2020
* Endpoints:
2121
* - /cdp/guid - Full CDP interface for Playwright MCP
22-
* - /extension/guid - Extension connection that exposes a thin chrome.* RPC.
23-
*
24-
* The relay owns CDP session management: it asks the extension for the user's
25-
* tab pick (extension.selectTab), then attaches the debugger and dispatches
26-
* Target.attachedToTarget events to Playwright. Additional tabs are created
27-
* either by Playwright (Target.createTarget → chrome.tabs.create) or by the
28-
* controlled tabs themselves (chrome.tabs.onCreated event from the extension).
22+
* - /extension/guid - Extension connection for chrome.debugger forwarding
2923
*/
3024

3125
import { spawn } from 'child_process';
@@ -64,15 +58,6 @@ type CDPResponse = {
6458
error?: { code?: number; message: string };
6559
};
6660

67-
type TabSession = {
68-
tabId: number;
69-
sessionId: string;
70-
targetInfo: any;
71-
// Child CDP sessionIds (workers, oopifs, ...) belonging to this tab,
72-
// tracked via Target.attachedToTarget / Target.detachedFromTarget events.
73-
childSessions: Set<string>;
74-
};
75-
7661
export class CDPRelayServer {
7762
private _wsHost: string;
7863
private _browserChannel: string;
@@ -83,10 +68,13 @@ export class CDPRelayServer {
8368
private _wss: WebSocketServer;
8469
private _playwrightConnection: WebSocket | null = null;
8570
private _extensionConnection: ExtensionConnection | null = null;
86-
private _extensionConnectionPromise!: ManualPromise<void>;
87-
88-
private _tabSessions = new Map<number, TabSession>();
71+
private _connectedTabInfo: {
72+
targetInfo: any;
73+
// Page sessionId that should be used by this connection.
74+
sessionId: string;
75+
} | undefined;
8976
private _nextSessionId: number = 1;
77+
private _extensionConnectionPromise!: ManualPromise<void>;
9078

9179
constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: string) {
9280
this._wsHost = addressToString(server.address(), { protocol: 'ws' });
@@ -226,28 +214,12 @@ export class CDPRelayServer {
226214
}
227215

228216
private _resetExtensionConnection() {
229-
this._tabSessions.clear();
217+
this._connectedTabInfo = undefined;
230218
this._extensionConnection = null;
231219
this._extensionConnectionPromise = new ManualPromise();
232220
void this._extensionConnectionPromise.catch(logUnhandledError);
233221
}
234222

235-
private _findTabSessionBySessionId(sessionId: string): TabSession | undefined {
236-
for (const session of this._tabSessions.values()) {
237-
if (session.sessionId === sessionId)
238-
return session;
239-
}
240-
return undefined;
241-
}
242-
243-
private _findTabSessionByChildSessionId(childSessionId: string): TabSession | undefined {
244-
for (const session of this._tabSessions.values()) {
245-
if (session.childSessions.has(childSessionId))
246-
return session;
247-
}
248-
return undefined;
249-
}
250-
251223
private _closePlaywrightConnection(reason: string) {
252224
if (this._playwrightConnection?.readyState === ws.OPEN)
253225
this._playwrightConnection.close(1000, reason);
@@ -273,92 +245,17 @@ export class CDPRelayServer {
273245

274246
private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
275247
switch (method) {
276-
case 'chrome.debugger.onEvent': {
277-
const [source, cdpMethod, cdpParams] = params as ExtensionEvents['chrome.debugger.onEvent']['params'];
278-
if (source.tabId === undefined)
279-
return;
280-
const tabSession = this._tabSessions.get(source.tabId);
281-
if (!tabSession)
282-
return;
283-
// Track child CDP sessions so we can route subsequent commands for
284-
// them to the correct tab. Target.attachedToTarget introduces a new
285-
// sessionId belonging to the same tab; Target.detachedFromTarget
286-
// releases it.
287-
const childSessionId = (cdpParams as { sessionId?: string } | undefined)?.sessionId;
288-
if (cdpMethod === 'Target.attachedToTarget' && childSessionId)
289-
tabSession.childSessions.add(childSessionId);
290-
else if (cdpMethod === 'Target.detachedFromTarget' && childSessionId)
291-
tabSession.childSessions.delete(childSessionId);
292-
// Top-level CDP events for the tab use the tab's relay sessionId.
293-
// Child CDP sessions (workers, oopifs) keep their own sessionId.
294-
const sessionId = source.sessionId || tabSession.sessionId;
248+
case 'forwardCDPEvent':
249+
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
295250
this._sendToPlaywright({
296251
sessionId,
297-
method: cdpMethod,
298-
params: cdpParams,
252+
method: params.method,
253+
params: params.params
299254
});
300255
break;
301-
}
302-
case 'chrome.debugger.onDetach': {
303-
const [source] = params as ExtensionEvents['chrome.debugger.onDetach']['params'];
304-
if (source.tabId !== undefined)
305-
this._detachTab(source.tabId);
306-
break;
307-
}
308-
case 'chrome.tabs.onCreated': {
309-
const [tab] = params as ExtensionEvents['chrome.tabs.onCreated']['params'];
310-
// A controlled tab opened a popup. Attach to it.
311-
if (tab.id !== undefined)
312-
void this._attachTab(tab.id).catch(logUnhandledError);
313-
break;
314-
}
315-
case 'chrome.tabs.onRemoved': {
316-
const [tabId] = params as ExtensionEvents['chrome.tabs.onRemoved']['params'];
317-
this._detachTab(tabId);
318-
break;
319-
}
320256
}
321257
}
322258

323-
private async _attachTab(tabId: number): Promise<TabSession> {
324-
const existing = this._tabSessions.get(tabId);
325-
if (existing)
326-
return existing;
327-
if (!this._extensionConnection)
328-
throw new Error('Extension not connected');
329-
await this._extensionConnection.send('chrome.debugger.attach', [{ tabId }, '1.3']);
330-
const result = await this._extensionConnection.send('chrome.debugger.sendCommand', [
331-
{ tabId },
332-
'Target.getTargetInfo',
333-
]);
334-
const targetInfo = result?.targetInfo;
335-
const sessionId = `pw-tab-${this._nextSessionId++}`;
336-
const tabSession: TabSession = { tabId, sessionId, targetInfo, childSessions: new Set() };
337-
this._tabSessions.set(tabId, tabSession);
338-
debugLogger(`Attached tab ${tabId} as session ${sessionId}`);
339-
this._sendToPlaywright({
340-
method: 'Target.attachedToTarget',
341-
params: {
342-
sessionId,
343-
targetInfo: { ...targetInfo, attached: true },
344-
waitingForDebugger: false,
345-
},
346-
});
347-
return tabSession;
348-
}
349-
350-
private _detachTab(tabId: number): void {
351-
const tabSession = this._tabSessions.get(tabId);
352-
if (!tabSession)
353-
return;
354-
this._tabSessions.delete(tabId);
355-
debugLogger(`Detached tab ${tabId} (session ${tabSession.sessionId})`);
356-
this._sendToPlaywright({
357-
method: 'Target.detachedFromTarget',
358-
params: { sessionId: tabSession.sessionId },
359-
});
360-
}
361-
362259
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
363260
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
364261
const { id, sessionId, method, params } = message;
@@ -391,53 +288,40 @@ export class CDPRelayServer {
391288
// Forward child session handling.
392289
if (sessionId)
393290
break;
394-
// Ask the user to pick the initial tab via the connect UI, then attach.
395-
if (!this._extensionConnection)
396-
throw new Error('Extension not connected');
397-
const { tabId } = await this._extensionConnection.send('extension.selectTab', []);
398-
await this._attachTab(tabId);
291+
// Simulate auto-attach behavior with real target info
292+
const { targetInfo } = await this._extensionConnection!.send('attachToTab', { });
293+
this._connectedTabInfo = {
294+
targetInfo,
295+
sessionId: `pw-tab-${this._nextSessionId++}`,
296+
};
297+
debugLogger('Simulating auto-attach');
298+
this._sendToPlaywright({
299+
method: 'Target.attachedToTarget',
300+
params: {
301+
sessionId: this._connectedTabInfo.sessionId,
302+
targetInfo: {
303+
...this._connectedTabInfo.targetInfo,
304+
attached: true,
305+
},
306+
waitingForDebugger: false
307+
}
308+
});
399309
return { };
400310
}
401-
case 'Target.createTarget': {
402-
if (!this._extensionConnection)
403-
throw new Error('Extension not connected');
404-
const tab = await this._extensionConnection.send('chrome.tabs.create', [{ url: params?.url }]);
405-
if (tab?.id === undefined)
406-
throw new Error('Failed to create tab');
407-
const tabSession = await this._attachTab(tab.id);
408-
return { targetId: tabSession.targetInfo?.targetId };
409-
}
410311
case 'Target.getTargetInfo': {
411-
if (!sessionId)
412-
return undefined;
413-
return this._findTabSessionBySessionId(sessionId)?.targetInfo;
312+
return this._connectedTabInfo?.targetInfo;
414313
}
415314
}
416-
if (!sessionId)
417-
throw new Error(`Unsupported command without sessionId: ${method}`);
418315
return await this._forwardToExtension(method, params, sessionId);
419316
}
420317

421-
private async _forwardToExtension(method: string, params: any, sessionId: string): Promise<any> {
318+
private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
422319
if (!this._extensionConnection)
423320
throw new Error('Extension not connected');
424-
// Resolve the sessionId to a tab session. Two cases:
425-
// 1. sessionId is a relay-level tab session (pw-tab-N) → strip and route by tabId.
426-
// 2. sessionId is a child CDP session (worker, oopif) → route to its owning tab,
427-
// keep the sessionId so the extension forwards it to chrome.debugger.
428-
let tabSession = this._findTabSessionBySessionId(sessionId);
429-
let cdpSessionId: string | undefined;
430-
if (!tabSession) {
431-
tabSession = this._findTabSessionByChildSessionId(sessionId);
432-
cdpSessionId = sessionId;
433-
}
434-
if (!tabSession)
435-
throw new Error(`No tab found for sessionId: ${sessionId}`);
436-
return await this._extensionConnection.send('chrome.debugger.sendCommand', [
437-
{ tabId: tabSession.tabId, sessionId: cdpSessionId },
438-
method,
439-
params,
440-
]);
321+
// Top level sessionId is only passed between the relay and the client.
322+
if (this._connectedTabInfo?.sessionId === sessionId)
323+
sessionId = undefined;
324+
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
441325
}
442326

443327
private _sendToPlaywright(message: CDPResponse): void {
@@ -469,7 +353,7 @@ class ExtensionConnection {
469353
this._ws.on('error', this._onError.bind(this));
470354
}
471355

472-
async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<ExtensionCommand[M]['result']> {
356+
async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<any> {
473357
if (this._ws.readyState !== ws.OPEN)
474358
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
475359
const id = ++this._lastId;

packages/playwright-core/src/tools/mcp/protocol.ts

Lines changed: 2 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -16,100 +16,22 @@
1616

1717
// Whenever the commands/events change, the version must be updated. The latest
1818
// extension version should be compatible with the old MCP clients.
19-
export const VERSION = 2;
19+
export const VERSION = 1;
2020

21-
// Structural mirrors of @types/chrome shapes used over the wire. The extension
22-
// imports the real chrome.* types and they are structurally compatible.
23-
export type Debuggee = { tabId?: number; extensionId?: string; targetId?: string };
24-
export type DebuggerSession = Debuggee & { sessionId?: string };
25-
export type TabCreateProperties = {
26-
active?: boolean;
27-
index?: number;
28-
openerTabId?: number;
29-
pinned?: boolean;
30-
url?: string;
31-
windowId?: number;
32-
};
33-
export type Tab = {
34-
id?: number;
35-
index: number;
36-
windowId: number;
37-
openerTabId?: number;
38-
url?: string;
39-
title?: string;
40-
active: boolean;
41-
pinned: boolean;
42-
};
43-
export type TabRemoveInfo = { windowId: number; isWindowClosing: boolean };
44-
45-
// Protocol v2: command params/results mirror chrome.* positional arguments,
46-
// so the extension can spread them straight into chrome.<api>.<method>(...).
4721
export type ExtensionCommand = {
48-
// chrome.debugger.attach(target, requiredVersion)
49-
'chrome.debugger.attach': {
50-
params: [target: Debuggee, requiredVersion: string];
51-
result: void;
52-
};
53-
// chrome.debugger.detach(target)
54-
'chrome.debugger.detach': {
55-
params: [target: Debuggee];
56-
result: void;
57-
};
58-
// chrome.debugger.sendCommand(target, method, commandParams?)
59-
'chrome.debugger.sendCommand': {
60-
params: [target: DebuggerSession, method: string, commandParams?: object];
61-
result: any;
62-
};
63-
// chrome.tabs.create(createProperties)
64-
'chrome.tabs.create': {
65-
params: [createProperties: TabCreateProperties];
66-
result: Tab;
67-
};
68-
// Playwright-specific: ask the user to pick a tab via the connect UI.
69-
'extension.selectTab': {
70-
params: [];
71-
result: { tabId: number };
72-
};
73-
};
74-
75-
// Event params mirror chrome.<api>.<event>.addListener callback signatures.
76-
export type ExtensionEvents = {
77-
// chrome.debugger.onEvent: (source, method, params?) => void
78-
'chrome.debugger.onEvent': {
79-
params: [source: DebuggerSession, method: string, eventParams?: object];
80-
};
81-
// chrome.debugger.onDetach: (source, reason) => void
82-
'chrome.debugger.onDetach': {
83-
params: [source: Debuggee, reason: string];
84-
};
85-
// chrome.tabs.onCreated: (tab) => void
86-
'chrome.tabs.onCreated': {
87-
params: [tab: Tab];
88-
};
89-
// chrome.tabs.onRemoved: (tabId, removeInfo) => void
90-
'chrome.tabs.onRemoved': {
91-
params: [tabId: number, removeInfo: TabRemoveInfo];
92-
};
93-
};
94-
95-
// Protocol v1: legacy single-tab interface. Kept for backward compatibility
96-
// with older MCP clients; will be removed in a future release.
97-
export type ExtensionCommandV1 = {
9822
'attachToTab': {
9923
params: {};
100-
result: { targetInfo: any };
10124
};
10225
'forwardCDPCommand': {
10326
params: {
10427
method: string,
10528
sessionId?: string
10629
params?: any,
10730
};
108-
result: any;
10931
};
11032
};
11133

112-
export type ExtensionEventsV1 = {
34+
export type ExtensionEvents = {
11335
'forwardCDPEvent': {
11436
params: {
11537
method: string,

0 commit comments

Comments
 (0)