Skip to content
Draft
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
3 changes: 2 additions & 1 deletion packages/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ When the LLM interacts with the browser for the first time, it will load a page

Several clients can be connected at the same time. Each one gets its own tab group, named after the client and colored apart from the other groups, and only sees the tabs in that group — a tab can belong to a single client at a time. Drag tabs in and out of a group to change what a client can reach, and use the extension's status page to see every connection and disconnect them individually.

In extension mode, an agent can call `browser_set_group_label({ label: "checkout flow" })` to name its own group `Playwright · checkout flow`. Labels are trimmed, limited to 50 characters, and cannot contain control characters. Duplicate titles get a numeric suffix such as `(2)`, and an empty label restores the client name. Renaming does not change the group's color or the tabs it controls. The label lasts for that connection; Chrome may shorten its display in the tab strip. This tool requires an up-to-date extension and reports an error when automatic grouping is disabled.

To disable automatic grouping, pass `--no-tab-group` alongside `--extension`, or set `PLAYWRIGHT_MCP_NO_TAB_GROUP=1`. Grouping stays enabled by default. With grouping disabled, the selected tab and tabs opened by the client stay in their existing groups, if any. This does not grant access to all existing tabs or change connection approval; each controlled tab still belongs to only one client. Use the status page to see controlled tabs and disconnect clients.

### Bypassing the Connection Approval Dialog
Expand Down Expand Up @@ -94,4 +96,3 @@ If the extension is installed in several Chrome profiles, the connection is made
```

The `PLAYWRIGHT_MCP_PROFILE_DIR_NAME` environment variable can be used instead of the option. The authentication token is specific to the profile, so use the one displayed in that profile.

11 changes: 11 additions & 0 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class PlaywrightExtension {
private _connections = new Map<number, ConnectedTabGroup>();
private _lastConnectionId = 0;
private _pendingConnections = new PendingConnections();
private _groupLabelQueue: Promise<void> = Promise.resolve();
// Service worker restarts lose all connection state, so any existing
// Playwright groups are stale. Connections wait on this before reconciling.
private _cleanupPromise: Promise<void>;
Expand Down Expand Up @@ -115,6 +116,16 @@ class PlaywrightExtension {
const taken = [...this._connections.values()].flatMap(group => group.groupStyle ? [group.groupStyle] : []);
const group = new ConnectedTabGroup(connection, tab, clientName, tabGroup ? uniqueGroupStyle(clientName, taken) : undefined, tabId => this._pendingConnections.has(tabId));
group.onclose = () => this._connections.delete(id);
connection.onsetgrouplabel = label => {
const result = this._groupLabelQueue.then(() => {
if (this._connections.get(id) !== group)
throw new Error('Connection closed');
const taken = [...this._connections.values()].flatMap(other => other !== group && other.groupStyle ? [other.groupStyle] : []);
return group.setGroupLabel(label, taken);
});
this._groupLabelQueue = result.then(() => {}, error => debugLog('Failed to label tab group:', error));
return result;
};
this._connections.set(id, group);

await Promise.all([
Expand Down
22 changes: 20 additions & 2 deletions packages/extension/src/connectedTabGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ export type GroupStyle = {
color: GroupColor;
};

export function uniqueGroupStyle(clientName: string | undefined, taken: readonly GroupStyle[]): GroupStyle {
function uniqueGroupTitle(name: string | undefined, taken: readonly GroupStyle[]): string {
const titles = new Set(taken.map(style => style.title));
const base = PLAYWRIGHT_GROUP_TITLE_PREFIX + (clientName || 'unknown');
const base = PLAYWRIGHT_GROUP_TITLE_PREFIX + (name || 'unknown');
let title = base;
for (let i = 2; titles.has(title); i++)
title = `${base} (${i})`;
return title;
}

export function uniqueGroupStyle(clientName: string | undefined, taken: readonly GroupStyle[]): GroupStyle {
const title = uniqueGroupTitle(clientName, taken);
const colors = new Set(taken.map(style => style.color));
const color = PLAYWRIGHT_GROUP_COLORS.find(candidate => !colors.has(candidate)) ?? PLAYWRIGHT_GROUP_COLORS[0];
return { title, color };
Expand Down Expand Up @@ -105,6 +109,20 @@ export class ConnectedTabGroup {
return [...this._groupTabIds];
}

async setGroupLabel(label: string, taken: readonly GroupStyle[]): Promise<string> {
if (!this.groupStyle)
throw new Error('Tab grouping is disabled for this connection.');
const title = uniqueGroupTitle(label || this.clientName, taken);
this.groupStyle.title = title;
const groupId = this._groupId;
if (groupId !== null) {
await retryOnDrag(async () => {
await chrome.tabGroups.update(groupId, { title });
});
}
return title;
}

close(reason: string): void {
this._connection.close(reason);
}
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/src/relayConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class RelayConnection {
onclose?: () => void;
ontabattached?: (tabId: number) => void;
ontabdetached?: (tabId: number) => void;
onsetgrouplabel?: (label: string) => Promise<string>;

get attachedTabs(): ReadonlySet<number> {
return this._attachedTabs;
Expand Down Expand Up @@ -277,6 +278,17 @@ export class RelayConnection {
}

private async _handleCommand(message: ProtocolCommand): Promise<any> {
if (message.method === 'extension.setGroupLabel') {
const rawLabel: unknown = message.params?.[0];
if (typeof rawLabel !== 'string')
throw new Error('Tab group label must be a string');
const label = rawLabel.trim();
if (label.length > 50 || /[\x00-\x1f\x7f]/.test(label))
throw new Error('Tab group label must be at most 50 characters without control characters');
if (!this.onsetgrouplabel)
throw new Error('No tab group for this connection');
return { title: await this.onsetgrouplabel(label) };
}
if (!ALLOWED_CHROME_COMMANDS.has(message.method))
throw new Error(`Unknown method: ${message.method}`);
const args = (message.params ?? []) as any[];
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright-core/src/tools/backend/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import debug from 'debug';
import { Context } from './context';
import { Response } from './response';
import { SessionLog } from './sessionLog';
import type { ContextConfig } from './context';
import type { ContextConfig, ExtensionSession } from './context';
import type { IdleTimer } from './idleTimer';
import type * as playwright from '../../..';
import type { Tool } from './tool';
Expand All @@ -33,6 +33,7 @@ const backendDebug = debug('pw:mcp:backend');
export type BrowserBackendOptions = {
dispose?: () => Promise<void>;
idleTimer?: IdleTimer;
extensionSession?: ExtensionSession;
};

export class BrowserBackend extends EventEmitter<{ disconnected: [], dynamictoolschange: [] }> implements ServerBackend {
Expand All @@ -45,6 +46,7 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [], dynamictool
private _browserContext: playwright.BrowserContext;
private _disposeCallback: (() => Promise<void>) | undefined;
private _idleTimer: IdleTimer | undefined;
private _extensionSession: ExtensionSession | undefined;

constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[], options: BrowserBackendOptions = {}) {
super();
Expand All @@ -53,6 +55,7 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [], dynamictool
this._browserContext = browserContext;
this._disposeCallback = options.dispose;
this._idleTimer = options.idleTimer;
this._extensionSession = options.extensionSession;
const markDisconnected = () => {
if (this._disconnected)
return;
Expand All @@ -71,6 +74,7 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [], dynamictool
sessionLog: this._sessionLog,
cwd: clientInfo.cwd,
onWebMCPToolsChanged: () => this.emit('dynamictoolschange'),
extensionSession: this._extensionSession,
});
}

Expand Down
6 changes: 6 additions & 0 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,19 @@ export type ContextConfig = {
initPage?: string[];
};
skillMode?: boolean;
extension?: boolean;
};

export type ExtensionSession = {
setGroupLabel(label: string): Promise<{ title: string }>;
};

type ContextOptions = {
config: ContextConfig;
sessionLog?: SessionLog;
cwd: string;
onWebMCPToolsChanged?: () => void;
extensionSession?: ExtensionSession;
};

export type RouteEntry = {
Expand Down
24 changes: 24 additions & 0 deletions packages/playwright-core/src/tools/backend/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ const browserTabs = defineTool({
},
});

const browserSetGroupLabel = defineTool({
capability: 'core-tabs',
extensionOnly: true,

schema: {
name: 'browser_set_group_label',
title: 'Label the tab group',
description: 'Label this connection\'s browser tab group so the user can identify its task. Only available in extension mode. An empty label restores the client name; duplicate titles get a numeric suffix.',
inputSchema: z.object({
label: z.string().trim().max(50).regex(/^[^\x00-\x1f\x7f]*$/).describe('Task label, up to 50 characters without control characters. Use an empty string to reset.'),
}),
type: 'action',
},

handle: async (context, params, response) => {
const session = context.options.extensionSession;
if (!session)
throw new Error('Tab group labels are only available when connected via the Playwright Extension.');
const { title } = await session.setGroupLabel(params.label);
response.addTextResult(`Tab group renamed to "${title}".`);
},
});

export default [
browserTabs,
browserSetGroupLabel,
];
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/backend/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type ModalState = FileUploadModalState | DialogModalState;
export type Tool<Input extends z.Schema = z.Schema> = {
capability: ToolCapability;
skillOnly?: boolean;
extensionOnly?: boolean;
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>, response: Response, signal?: AbortSignal) => Promise<void>;
};
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/tools/backend/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export const browserTools: Tool<any>[] = [
...webstorage,
];

export function filteredTools(config: Pick<ContextConfig, 'capabilities'>) {
return browserTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)).filter(tool => !tool.skillOnly).map(tool => ({
export function filteredTools(config: Pick<ContextConfig, 'capabilities' | 'extension'>) {
return browserTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)).filter(tool => !tool.skillOnly && (!tool.extensionOnly || config.extension)).map(tool => ({
...tool,
schema: {
...tool.schema,
Expand Down
7 changes: 5 additions & 2 deletions packages/playwright-core/src/tools/mcp/browserFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ import type { ClientInfo } from '../utils/mcp/server';
import type { Playwright } from '../../client/playwright';
import type * as playwrightTypes from '../../..';
import type { BrowserInfo } from '../../serverRegistry';
import type { ExtensionSession } from '../backend/context';

export type BrowserWithInfo = {
browser: playwrightTypes.Browser,
browserInfo: BrowserInfo,
endpoint: string,
ownership: 'attached' | 'own',
idleTimer?: IdleTimer,
extensionSession?: ExtensionSession,
};

export type BindOptions = {
Expand All @@ -67,6 +69,7 @@ async function createBrowser(config: FullConfig, clientInfo: ClientInfo, cliOpti
return await createRemoteBrowser(config);

let browser: playwrightTypes.Browser;
let extensionSession: ExtensionSession | undefined;
let ownership: 'attached' | 'own' = 'own';
if (config.browser.cdpEndpoint) {
browser = await createCDPBrowser(config, clientInfo);
Expand All @@ -76,7 +79,7 @@ async function createBrowser(config: FullConfig, clientInfo: ClientInfo, cliOpti
ownership = 'own';
} else if (config.extension) {
const { channel, executablePath, profileDirName } = resolveExtensionOptions(cliOptions);
browser = await createExtensionBrowser(channel, executablePath, config.browser.userDataDir, profileDirName, clientInfo.clientName, config.tabGroup);
({ browser, extensionSession } = await createExtensionBrowser(channel, executablePath, config.browser.userDataDir, profileDirName, clientInfo.clientName, config.tabGroup));
ownership = 'attached';
} else {
browser = await createPersistentBrowser(config, clientInfo);
Expand All @@ -85,7 +88,7 @@ async function createBrowser(config: FullConfig, clientInfo: ClientInfo, cliOpti

try {
const { endpoint } = await browser.bind(bindOptions.title, { workspaceDir: bindOptions.workspaceDir });
return { browser, browserInfo: browserInfo(browser, config), endpoint, ownership };
return { browser, browserInfo: browserInfo(browser, config), endpoint, ownership, extensionSession };
} catch (error) {
await browser.close().catch(() => {});
throw error;
Expand Down
12 changes: 12 additions & 0 deletions packages/playwright-core/src/tools/mcp/cdpRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ export class CDPRelayServer {
return `${this._wsHost}${this._extensionPath}`;
}

async setGroupLabel(label: string): Promise<{ title: string }> {
if (!this._extensionConnection)
throw new Error('Extension not connected');
try {
return await this._extensionConnection.send('extension.setGroupLabel', [label]);
} catch (error) {
if (error instanceof Error && error.message === 'Unknown method: extension.setGroupLabel')
throw new Error('The installed Playwright Extension does not support tab group labels. Please update the extension.');
throw error;
}
}

async establishExtensionConnection(clientName: string, tabGroup = true) {
debugLogger('Establishing extension connection');
await this._openConnectPageInBrowser(clientName, tabGroup);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import { findPlaywrightExtensionProfile, isExtensionInstalledInProfile, playwrig
import { CDPRelayServer } from './cdpRelay';

import type * as playwrightTypes from '../../..';
import type { ExtensionSession } from '../backend/context';

const debugLogger = debug('pw:mcp:relay');

export async function createExtensionBrowser(channel: string, executablePath: string | undefined, customUserDataDir: string | undefined, profileDirName: string | undefined, clientName: string, tabGroup = true): Promise<playwrightTypes.Browser> {
export async function createExtensionBrowser(channel: string, executablePath: string | undefined, customUserDataDir: string | undefined, profileDirName: string | undefined, clientName: string, tabGroup = true): Promise<{ browser: playwrightTypes.Browser, extensionSession: ExtensionSession }> {
customUserDataDir ??= process.env.PWTEST_EXTENSION_USER_DATA_DIR;
// Custom executablePath may target a browser in a different filesystem (e.g. Windows chrome.exe from WSL2), so the local profile path is not meaningful.
const userDataDir = customUserDataDir ?? (executablePath ? undefined : defaultUserDataDirForChannel(channel));
Expand All @@ -42,7 +43,7 @@ export async function createExtensionBrowser(channel: string, executablePath: st
await relay.establishExtensionConnection(clientName, tabGroup);
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true, timeout: 0, noDefaults: true });
browser.on('disconnected', () => relay.stop());
return browser;
return { browser, extensionSession: relay };
} catch (error) {
relay.stop();
throw error;
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright-core/src/tools/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ export async function createConnection(userConfig: Config = {}, contextGetter?:
toolSchemas: tools.map(tool => tool.schema),
ignoreRoots: config.ignoreRoots,
create: async (clientInfo: ClientInfo) => {
const info = contextGetter ? undefined : await createBrowserWithInfo(config, clientInfo, {}, { title: clientInfo.clientName, workspaceDir: clientInfo.cwd });
const browser = contextGetter
? new SimpleBrowser(await contextGetter())
: (await createBrowserWithInfo(config, clientInfo, {}, { title: clientInfo.clientName, workspaceDir: clientInfo.cwd })).browser;
: info!.browser;
const context = config.browser.isolated ? await browser.newContext(config.browser.contextOptions) : browser.contexts()[0];
return new BrowserBackend(config, context, tools);
return new BrowserBackend(config, context, tools, { extensionSession: info?.extensionSession });
},
};
return createServer('api', packageJSON.version, backendFactory, Promise.resolve(), false);
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export function decorateMCPCommand(command: Command) {

return new BrowserBackend(config, browserContext, tools, {
idleTimer: clientIdleTimer ?? info.idleTimer,
extensionSession: info.extensionSession,
dispose: async () => {
clientIdleTimer?.dispose();
clientCount--;
Expand Down
9 changes: 7 additions & 2 deletions packages/playwright-core/src/tools/mcp/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
* limitations under the License.
*/

// The protocol version defined in this file. Bumped whenever the
// commands/events change. Sent to the extension, which rejects clients
// The protocol version defined in this file. Bumped for incompatible
// command/event changes. Sent to the extension, which rejects clients
// requesting a version it does not support.
export const VERSION = 2;

Expand Down Expand Up @@ -71,6 +71,11 @@ export type ExtensionCommandV2 = {
params: [tabIds: number | number[]];
result: void;
};
// Optional: older v2 extensions return "Unknown method".
'extension.setGroupLabel': {
params: [label: string];
result: { title: string };
};
};

// Protocol v2 events mirror chrome.<api>.<event>.addListener callback signatures.
Expand Down
28 changes: 27 additions & 1 deletion tests/extension/multi-connection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ test(`two clients connect at the same time, each in its own tab group`, {
});

test(`second client cannot pick a tab owned by the first one`, {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41838' },
annotation: [
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41838' },
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/43823' },
],
}, async ({ browserWithExtension, startClient, server }) => {
server.setContent('/second', '<title>Second</title><body>Second page</body>', 'text/html');

Expand Down Expand Up @@ -126,6 +129,29 @@ test(`second client cannot pick a tab owned by the first one`, {
{ title: 'Playwright · test', color: 'green' },
{ title: 'Playwright · test (2)', color: 'blue' },
]);

expect((await a.client.listTools()).tools.map(tool => tool.name)).toContain('browser_set_group_label');
expect(await a.client.callTool({
name: 'browser_set_group_label', arguments: { label: ' checkout flow ' },
})).toHaveResponse({ result: 'Tab group renamed to "Playwright · checkout flow".' });
await expect.poll(() => playwrightGroups(browserContext)).toEqual([
{ title: 'Playwright · checkout flow', color: 'green' },
{ title: 'Playwright · test (2)', color: 'blue' },
]);
expect(await b.client.callTool({
name: 'browser_set_group_label', arguments: { label: 'checkout flow' },
})).toHaveResponse({ result: 'Tab group renamed to "Playwright · checkout flow (2)".' });
await expect.poll(() => playwrightGroups(browserContext)).toEqual([
{ title: 'Playwright · checkout flow', color: 'green' },
{ title: 'Playwright · checkout flow (2)', color: 'blue' },
]);
expect(await a.client.callTool({
name: 'browser_set_group_label', arguments: { label: '' },
})).toHaveResponse({ result: 'Tab group renamed to "Playwright · test".' });
await expect.poll(() => playwrightGroups(browserContext)).toEqual([
{ title: 'Playwright · checkout flow (2)', color: 'blue' },
{ title: 'Playwright · test', color: 'green' },
]);
});

test(`connect page opened inside another client's group is released`, {
Expand Down
Loading
Loading