Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 153 additions & 37 deletions packages/playwright-core/src/tools/mcp/cdpRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@
*
* Endpoints:
* - /cdp/guid - Full CDP interface for Playwright MCP
* - /extension/guid - Extension connection for chrome.debugger forwarding
* - /extension/guid - Extension connection that exposes a thin chrome.* RPC.
*
* The relay owns CDP session management: it asks the extension for the user's
* tab pick (extension.selectTab), then attaches the debugger and dispatches
* Target.attachedToTarget events to Playwright. Additional tabs are created
* either by Playwright (Target.createTarget → chrome.tabs.create) or by the
* controlled tabs themselves (chrome.tabs.onCreated event from the extension).
*/

import { spawn } from 'child_process';
Expand Down Expand Up @@ -57,6 +63,15 @@ type CDPResponse = {
error?: { code?: number; message: string };
};

type TabSession = {
tabId: number;
sessionId: string;
targetInfo: any;
// Child CDP sessionIds (workers, oopifs, ...) belonging to this tab,
// tracked via Target.attachedToTarget / Target.detachedFromTarget events.
childSessions: Set<string>;
};

export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
Expand All @@ -67,14 +82,11 @@ export class CDPRelayServer {
private _wss: WebSocketServer;
private _playwrightConnection: WebSocket | null = null;
private _extensionConnection: ExtensionConnection | null = null;
private _connectedTabInfo: {
targetInfo: any;
// Page sessionId that should be used by this connection.
sessionId: string;
} | undefined;
private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>;

private _tabSessions = new Map<number, TabSession>();
private _nextSessionId: number = 1;

constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: string) {
this._wsHost = addressToString(server.address(), { protocol: 'ws' });
this._browserChannel = browserChannel;
Expand Down Expand Up @@ -213,12 +225,28 @@ export class CDPRelayServer {
}

private _resetExtensionConnection() {
this._connectedTabInfo = undefined;
this._tabSessions.clear();
this._extensionConnection = null;
this._extensionConnectionPromise = new ManualPromise();
void this._extensionConnectionPromise.catch(logUnhandledError);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pre-existing landmine still here: _closeExtensionConnection rejects the current promise, then _resetExtensionConnection immediately replaces _extensionConnectionPromise with a new one. The void this._extensionConnectionPromise.catch(logUnhandledError) on the next line attaches to the new promise, not the just-rejected one. If no one is currently awaiting the old promise via Promise.race, you get an unhandled rejection. Either attach the catch handler before the reject, or reject after resetting.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

_extensionConnectionPromise is only assigned in this method => it always has a catch handler.

}

private _findTabSessionBySessionId(sessionId: string): TabSession | undefined {
for (const session of this._tabSessions.values()) {
if (session.sessionId === sessionId)
return session;
}
return undefined;
}

private _findTabSessionByChildSessionId(childSessionId: string): TabSession | undefined {
for (const session of this._tabSessions.values()) {
if (session.childSessions.has(childSessionId))
return session;
}
return undefined;
}

private _closePlaywrightConnection(reason: string) {
if (this._playwrightConnection?.readyState === ws.OPEN)
this._playwrightConnection.close(1000, reason);
Expand All @@ -244,17 +272,92 @@ export class CDPRelayServer {

private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
switch (method) {
case 'forwardCDPEvent':
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
case 'chrome.debugger.onEvent': {
const [source, cdpMethod, cdpParams] = params as ExtensionEvents['chrome.debugger.onEvent']['params'];
if (source.tabId === undefined)
return;
const tabSession = this._tabSessions.get(source.tabId);
if (!tabSession)
return;
// Track child CDP sessions so we can route subsequent commands for
// them to the correct tab. Target.attachedToTarget introduces a new
// sessionId belonging to the same tab; Target.detachedFromTarget
// releases it.
const childSessionId = (cdpParams as { sessionId?: string } | undefined)?.sessionId;
if (cdpMethod === 'Target.attachedToTarget' && childSessionId)
tabSession.childSessions.add(childSessionId);
else if (cdpMethod === 'Target.detachedFromTarget' && childSessionId)
tabSession.childSessions.delete(childSessionId);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Popup-via-auto-attach is double-tracked and routed to the wrong tab. When a controlled tab opens a popup with window.open, Chrome dispatches both:

  1. chrome.debugger.onEvent with Target.attachedToTarget on the opener tab — its params.sessionId is recorded here as a child CDP session of the opener.
  2. chrome.tabs.onCreated for the popup as a separate top-level tab — _attachTab then creates a brand-new pw-tab-N for it.

So the same popup ends up represented twice. Worse: when Playwright sends commands using the child sessionId, _forwardToExtension routes them to tabSession.tabId (the opener), but the actual debuggee is the popup's tabId — commands either fail or hit the wrong target. Needs a single canonical mapping (e.g. ignore the auto-attach event when the new target is itself a top-level tab, or skip chrome.tabs.onCreated when we already saw the auto-attach).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

chrome.debugger.onEvent only sends Target.attachedToTarget for child targets (oopifs, workers)

// Top-level CDP events for the tab use the tab's relay sessionId.
// Child CDP sessions (workers, oopifs) keep their own sessionId.
const sessionId = source.sessionId || tabSession.sessionId;
this._sendToPlaywright({
sessionId,
method: params.method,
params: params.params
method: cdpMethod,
params: cdpParams,
});
break;
}
case 'chrome.debugger.onDetach': {
const [source] = params as ExtensionEvents['chrome.debugger.onDetach']['params'];
if (source.tabId !== undefined)
this._detachTab(source.tabId);
break;
}
case 'chrome.tabs.onCreated': {
const [tab] = params as ExtensionEvents['chrome.tabs.onCreated']['params'];
// A controlled tab opened a popup. Attach to it.
if (tab.id !== undefined)
void this._attachTab(tab.id).catch(logUnhandledError);
break;
}
case 'chrome.tabs.onRemoved': {
const [tabId] = params as ExtensionEvents['chrome.tabs.onRemoved']['params'];
this._detachTab(tabId);
break;
}
}
}

private async _attachTab(tabId: number): Promise<TabSession> {
const existing = this._tabSessions.get(tabId);
if (existing)
return existing;
if (!this._extensionConnection)
throw new Error('Extension not connected');
await this._extensionConnection.send('chrome.debugger.attach', [{ tabId }, '1.3']);
const result = await this._extensionConnection.send('chrome.debugger.sendCommand', [
{ tabId },
'Target.getTargetInfo',
]);
const targetInfo = result?.targetInfo;
const sessionId = `pw-tab-${this._nextSessionId++}`;
const tabSession: TabSession = { tabId, sessionId, targetInfo, childSessions: new Set() };
this._tabSessions.set(tabId, tabSession);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Resource leak on partial-attach failure. If chrome.debugger.sendCommand('Target.getTargetInfo') rejects after chrome.debugger.attach succeeded, the tab stays attached on the chrome side forever — no cleanup chrome.debugger.detach call, and no map entry to drive a later detach. Wrap the getTargetInfo call in a try/catch that detaches before rethrowing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

If 'Target.getTargetInfo' it means the tab has detached/closed. Both are fine.

debugLogger(`Attached tab ${tabId} as session ${sessionId}`);
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
});
return tabSession;
}

private _detachTab(tabId: number): void {
const tabSession = this._tabSessions.get(tabId);
if (!tabSession)
return;
this._tabSessions.delete(tabId);
debugLogger(`Detached tab ${tabId} (session ${tabSession.sessionId})`);
this._sendToPlaywright({
method: 'Target.detachedFromTarget',
params: { sessionId: tabSession.sessionId },
});
}

private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
const { id, sessionId, method, params } = message;
Expand Down Expand Up @@ -287,40 +390,53 @@ export class CDPRelayServer {
// Forward child session handling.
if (sessionId)
break;
// Simulate auto-attach behavior with real target info
const { targetInfo } = await this._extensionConnection!.send('attachToTab', { });
this._connectedTabInfo = {
targetInfo,
sessionId: `pw-tab-${this._nextSessionId++}`,
};
debugLogger('Simulating auto-attach');
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectedTabInfo.sessionId,
targetInfo: {
...this._connectedTabInfo.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
// Ask the user to pick the initial tab via the connect UI, then attach.
if (!this._extensionConnection)
throw new Error('Extension not connected');
const { tabId } = await this._extensionConnection.send('extension.selectTab', []);
await this._attachTab(tabId);
return { };
}
case 'Target.createTarget': {
if (!this._extensionConnection)
throw new Error('Extension not connected');
const tab = await this._extensionConnection.send('chrome.tabs.create', [{ url: params?.url }]);
if (tab?.id === undefined)
throw new Error('Failed to create tab');
const tabSession = await this._attachTab(tab.id);
return { targetId: tabSession.targetInfo?.targetId };
}
case 'Target.getTargetInfo': {
return this._connectedTabInfo?.targetInfo;
if (!sessionId)
return undefined;
return this._findTabSessionBySessionId(sessionId)?.targetInfo;
}
}
if (!sessionId)
throw new Error(`Unsupported command without sessionId: ${method}`);
return await this._forwardToExtension(method, params, sessionId);
}

private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
private async _forwardToExtension(method: string, params: any, sessionId: string): Promise<any> {
if (!this._extensionConnection)
throw new Error('Extension not connected');
// Top level sessionId is only passed between the relay and the client.
if (this._connectedTabInfo?.sessionId === sessionId)
sessionId = undefined;
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
// Resolve the sessionId to a tab session. Two cases:
// 1. sessionId is a relay-level tab session (pw-tab-N) → strip and route by tabId.
// 2. sessionId is a child CDP session (worker, oopif) → route to its owning tab,
// keep the sessionId so the extension forwards it to chrome.debugger.
let tabSession = this._findTabSessionBySessionId(sessionId);
let cdpSessionId: string | undefined;
if (!tabSession) {
tabSession = this._findTabSessionByChildSessionId(sessionId);
cdpSessionId = sessionId;
}
if (!tabSession)
throw new Error(`No tab found for sessionId: ${sessionId}`);
return await this._extensionConnection.send('chrome.debugger.sendCommand', [
{ tabId: tabSession.tabId, sessionId: cdpSessionId },
method,
params,
]);
}

private _sendToPlaywright(message: CDPResponse): void {
Expand Down Expand Up @@ -352,7 +468,7 @@ class ExtensionConnection {
this._ws.on('error', this._onError.bind(this));
}

async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<any> {
async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<ExtensionCommand[M]['result']> {
if (this._ws.readyState !== ws.OPEN)
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
const id = ++this._lastId;
Expand Down
82 changes: 80 additions & 2 deletions packages/playwright-core/src/tools/mcp/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,100 @@

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

// Structural mirrors of @types/chrome shapes used over the wire. The extension
// imports the real chrome.* types and they are structurally compatible.
export type Debuggee = { tabId?: number; extensionId?: string; targetId?: string };
export type DebuggerSession = Debuggee & { sessionId?: string };
export type TabCreateProperties = {
active?: boolean;
index?: number;
openerTabId?: number;
pinned?: boolean;
url?: string;
windowId?: number;
};
export type Tab = {
id?: number;
index: number;
windowId: number;
openerTabId?: number;
url?: string;
title?: string;
active: boolean;
pinned: boolean;
};
export type TabRemoveInfo = { windowId: number; isWindowClosing: boolean };

// Protocol v2: command params/results mirror chrome.* positional arguments,
// so the extension can spread them straight into chrome.<api>.<method>(...).
export type ExtensionCommand = {
// chrome.debugger.attach(target, requiredVersion)
'chrome.debugger.attach': {
params: [target: Debuggee, requiredVersion: string];
result: void;
};
// chrome.debugger.detach(target)
'chrome.debugger.detach': {
params: [target: Debuggee];
result: void;
};
// chrome.debugger.sendCommand(target, method, commandParams?)
'chrome.debugger.sendCommand': {
params: [target: DebuggerSession, method: string, commandParams?: object];
result: any;
};
// chrome.tabs.create(createProperties)
'chrome.tabs.create': {
params: [createProperties: TabCreateProperties];
result: Tab;
};
// Playwright-specific: ask the user to pick a tab via the connect UI.
'extension.selectTab': {
params: [];
result: { tabId: number };
};
};

// Event params mirror chrome.<api>.<event>.addListener callback signatures.
export type ExtensionEvents = {
// chrome.debugger.onEvent: (source, method, params?) => void
'chrome.debugger.onEvent': {
params: [source: DebuggerSession, method: string, eventParams?: object];
};
// chrome.debugger.onDetach: (source, reason) => void
'chrome.debugger.onDetach': {
params: [source: Debuggee, reason: string];
};
// chrome.tabs.onCreated: (tab) => void
'chrome.tabs.onCreated': {
params: [tab: Tab];
};
// chrome.tabs.onRemoved: (tabId, removeInfo) => void
'chrome.tabs.onRemoved': {
params: [tabId: number, removeInfo: TabRemoveInfo];
};
};

// Protocol v1: legacy single-tab interface. Kept for backward compatibility
// with older MCP clients; will be removed in a future release.
export type ExtensionCommandV1 = {
'attachToTab': {
params: {};
result: { targetInfo: any };
};
'forwardCDPCommand': {
params: {
method: string,
sessionId?: string
params?: any,
};
result: any;
};
};

export type ExtensionEvents = {
export type ExtensionEventsV1 = {
'forwardCDPEvent': {
params: {
method: string,
Expand Down
Loading