Skip to content

Commit 37d58bd

Browse files
authored
chore(mcp): run commands from terminal (#38820)
1 parent db2a052 commit 37d58bd

File tree

8 files changed

+500
-5
lines changed

8 files changed

+500
-5
lines changed

packages/playwright/src/mcp/DEPS.list

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
./sdk/
33
./browser/
44
./extension/
5+
./terminal/daemon.ts
56

67
[index.ts]
78
./sdk/

packages/playwright/src/mcp/browser/tab.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,16 +284,18 @@ export class Tab extends EventEmitter<TabEventsInterface> {
284284
await this._raceAgainstModalStates(() => waitForCompletion(this, callback));
285285
}
286286

287-
async refLocator(params: { element: string, ref: string }): Promise<{ locator: Locator, resolved: string }> {
287+
async refLocator(params: { element?: string, ref: string }): Promise<{ locator: Locator, resolved: string }> {
288288
await this._initializedPromise;
289289
return (await this.refLocators([params]))[0];
290290
}
291291

292-
async refLocators(params: { element: string, ref: string }[]): Promise<{ locator: Locator, resolved: string }[]> {
292+
async refLocators(params: { element?: string, ref: string }[]): Promise<{ locator: Locator, resolved: string }[]> {
293293
await this._initializedPromise;
294294
return Promise.all(params.map(async param => {
295295
try {
296-
const locator = this.page.locator(`aria-ref=${param.ref}`).describe(param.element) as Locator;
296+
let locator = this.page.locator(`aria-ref=${param.ref}`);
297+
if (param.element)
298+
locator = locator.describe(param.element);
297299
const { resolvedSelector } = await locator._resolveSelector();
298300
return { locator, resolved: asLocator('javascript', resolvedSelector) };
299301
} catch (e) {

packages/playwright/src/mcp/browser/tools/snapshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const snapshot = defineTool({
4747
});
4848

4949
export const elementSchema = z.object({
50-
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
50+
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
5151
ref: z.string().describe('Exact target element reference from the page snapshot'),
5252
});
5353

packages/playwright/src/mcp/program.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { colors, ProgramOption } from 'playwright-core/lib/utilsBundle';
2222
import { registry } from 'playwright-core/lib/server';
2323

2424
import * as mcpServer from './sdk/server';
25+
import { startMcpDaemonServer } from './terminal/daemon';
2526
import { commaSeparatedList, dotenvFileLoader, enumParser, headerParser, numberParser, resolutionParser, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
2627
import { setupExitWatchdog } from './browser/watchdog';
2728
import { contextFactory } from './browser/browserContextFactory';
@@ -73,6 +74,7 @@ export function decorateCommand(command: Command, version: string) {
7374
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
7475
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280x720"', resolutionParser.bind(null, '--viewport-size'))
7576
.addOption(new ProgramOption('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
77+
.addOption(new ProgramOption('--daemon <socket>', 'run as daemon').hideHelp())
7678
.action(async options => {
7779
setupExitWatchdog();
7880

@@ -106,6 +108,18 @@ export function decorateCommand(command: Command, version: string) {
106108
return;
107109
}
108110

111+
if (options.daemon) {
112+
const serverBackendFactory: mcpServer.ServerBackendFactory = {
113+
name: 'Playwright',
114+
nameInConfig: 'playwright-daemon',
115+
version,
116+
create: () => new BrowserServerBackend(config, browserContextFactory)
117+
};
118+
const socketPath = await startMcpDaemonServer(options.daemon, serverBackendFactory);
119+
console.error(`Daemon server listening on ${socketPath}`);
120+
return;
121+
}
122+
109123
const factory: mcpServer.ServerBackendFactory = {
110124
name: 'Playwright',
111125
nameInConfig: 'playwright',

packages/playwright/src/mcp/sdk/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ function addServerListener(server: Server, event: 'close' | 'initialized', liste
172172
};
173173
}
174174

175-
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number, allowedHosts?: string[] }) {
175+
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number, allowedHosts?: string[], socketPath?: string }) {
176176
if (options.port === undefined) {
177177
await connect(serverBackendFactory, new mcpBundle.StdioServerTransport(), false);
178178
return;
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/* eslint-disable no-console */
18+
19+
import { spawn } from 'child_process';
20+
import fs from 'fs';
21+
import net from 'net';
22+
import os from 'os';
23+
import path from 'path';
24+
import { program, debug } from 'playwright-core/lib/utilsBundle';
25+
import { SocketConnection } from './socketConnection';
26+
27+
import type * as mcp from '../sdk/exports';
28+
29+
const debugCli = debug('pw:cli');
30+
31+
const packageJSON = require('../../../package.json');
32+
33+
program
34+
.version('Version ' + (process.env.PW_CLI_DISPLAY_VERSION || packageJSON.version))
35+
.name('playwright-command');
36+
37+
function addCommand(name: string, description: string, action: (...args: any[]) => Promise<void>) {
38+
program
39+
.command(name)
40+
.description(description)
41+
.action(action);
42+
}
43+
44+
addCommand('navigate <url>', 'open url in the browser', async url => {
45+
await runMcpCommand('browser_navigate', { url });
46+
});
47+
48+
addCommand('close', 'close the browser', async () => {
49+
await runMcpCommand('browser_close', {});
50+
});
51+
52+
// snapshot.ts
53+
addCommand('click <ref>', 'click an element using a ref from a snapshot, e.g. e67', async ref => {
54+
await runMcpCommand('browser_click', { ref });
55+
});
56+
57+
addCommand('snapshot', 'get accessible snapshot of the current page', async () => {
58+
await runMcpCommand('browser_snapshot', {});
59+
});
60+
61+
addCommand('drag <startRef> <endRef>', 'drag from one element to another', async (startRef, endRef) => {
62+
await runMcpCommand('browser_drag', { startRef, endRef });
63+
});
64+
65+
addCommand('hover <ref>', 'hover over an element', async ref => {
66+
await runMcpCommand('browser_hover', { ref });
67+
});
68+
69+
addCommand('select <ref> <values...>', 'select option(s) in a dropdown', async (ref, values) => {
70+
await runMcpCommand('browser_select_option', { ref, values });
71+
});
72+
73+
// TODO: remove?
74+
addCommand('locator <ref>', 'generate a locator for an element', async ref => {
75+
await runMcpCommand('browser_generate_locator', { ref });
76+
});
77+
78+
// keyboard.ts
79+
addCommand('press <key>', 'press a key on the keyboard', async key => {
80+
await runMcpCommand('browser_press_key', { key });
81+
});
82+
83+
addCommand('type <ref> <text>', 'type text into an element', async (ref, text) => {
84+
await runMcpCommand('browser_type', { ref, text });
85+
});
86+
87+
// navigate.ts
88+
addCommand('back', 'go back to the previous page', async () => {
89+
await runMcpCommand('browser_navigate_back', {});
90+
});
91+
92+
// wait.ts
93+
addCommand('wait <time>', 'wait for a specified time in seconds', async time => {
94+
await runMcpCommand('browser_wait_for', { time: parseFloat(time) });
95+
});
96+
97+
addCommand('wait-for-text <text>', 'wait for text to appear', async text => {
98+
await runMcpCommand('browser_wait_for', { text });
99+
});
100+
101+
// dialogs.ts
102+
addCommand('dialog-accept [promptText]', 'accept a dialog', async promptText => {
103+
await runMcpCommand('browser_handle_dialog', { accept: true, promptText });
104+
});
105+
106+
addCommand('dialog-dismiss', 'dismiss a dialog', async () => {
107+
await runMcpCommand('browser_handle_dialog', { accept: false });
108+
});
109+
110+
// screenshot.ts
111+
addCommand('screenshot [filename]', 'take a screenshot of the current page', async filename => {
112+
await runMcpCommand('browser_take_screenshot', { filename });
113+
});
114+
115+
// common.ts (resize)
116+
addCommand('resize <width> <height>', 'resize the browser window', async (width, height) => {
117+
await runMcpCommand('browser_resize', { width: parseInt(width, 10), height: parseInt(height, 10) });
118+
});
119+
120+
// files.ts
121+
addCommand('upload <paths...>', 'upload files', async paths => {
122+
await runMcpCommand('browser_file_upload', { paths });
123+
});
124+
125+
// tabs.ts
126+
addCommand('tabs', 'list all browser tabs', async () => {
127+
await runMcpCommand('browser_tabs', { action: 'list' });
128+
});
129+
130+
addCommand('tab-new', 'create a new browser tab', async () => {
131+
await runMcpCommand('browser_tabs', { action: 'new' });
132+
});
133+
134+
addCommand('tab-close [index]', 'close a browser tab', async index => {
135+
await runMcpCommand('browser_tabs', { action: 'close', index: index !== undefined ? parseInt(index, 10) : undefined });
136+
});
137+
138+
addCommand('tab-select <index>', 'select a browser tab', async index => {
139+
await runMcpCommand('browser_tabs', { action: 'select', index: parseInt(index, 10) });
140+
});
141+
142+
143+
async function runMcpCommand(name: string, args: mcp.CallToolRequest['params']['arguments']) {
144+
const session = await connectToDaemon();
145+
const result = await session.callTool(name, args);
146+
printResult(result);
147+
session.dispose();
148+
}
149+
150+
function printResult(result: mcp.CallToolResult) {
151+
for (const content of result.content) {
152+
if (content.type === 'text')
153+
console.log(content.text);
154+
else
155+
console.log(`<${content.type} content>`);
156+
}
157+
}
158+
159+
async function socketExists(socketPath: string): Promise<boolean> {
160+
try {
161+
const stat = await fs.promises.stat(socketPath);
162+
if (stat?.isSocket())
163+
return true;
164+
} catch (e) {
165+
}
166+
return false;
167+
}
168+
169+
class SocketSession {
170+
private _connection: SocketConnection;
171+
private _nextMessageId = 1;
172+
private _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
173+
174+
constructor(connection: SocketConnection) {
175+
this._connection = connection;
176+
this._connection.onmessage = message => this._onMessage(message);
177+
this._connection.onclose = () => this.dispose();
178+
}
179+
180+
181+
async callTool(name: string, args: mcp.CallToolRequest['params']['arguments']): Promise<mcp.CallToolResult> {
182+
return this._send(name, args);
183+
}
184+
185+
private async _send(method: string, params: any = {}): Promise<any> {
186+
const messageId = this._nextMessageId++;
187+
const message = {
188+
id: messageId,
189+
method,
190+
params,
191+
};
192+
await this._connection.send(message);
193+
return new Promise<any>((resolve, reject) => {
194+
this._callbacks.set(messageId, { resolve, reject, error: new Error(`Error in method: ${method}`) });
195+
});
196+
}
197+
198+
dispose() {
199+
for (const callback of this._callbacks.values())
200+
callback.reject(callback.error);
201+
this._callbacks.clear();
202+
this._connection.close();
203+
}
204+
205+
private _onMessage(object: any) {
206+
if (object.id && this._callbacks.has(object.id)) {
207+
const callback = this._callbacks.get(object.id)!;
208+
this._callbacks.delete(object.id);
209+
if (object.error) {
210+
callback.error.cause = new Error(object.error);
211+
callback.reject(callback.error);
212+
} else {
213+
callback.resolve(object.result);
214+
}
215+
} else if (object.id) {
216+
throw new Error(`Unexpected message id: ${object.id}`);
217+
} else {
218+
throw new Error(`Unexpected message without id: ${JSON.stringify(object)}`);
219+
}
220+
}
221+
}
222+
223+
function daemonSocketPath(): string {
224+
if (os.platform() === 'win32')
225+
return path.join('\\\\.\\pipe', 'pw-daemon.sock');
226+
return path.join(os.homedir(), '.playwright', 'pw-daemon.sock');
227+
}
228+
229+
async function connectToDaemon(): Promise<SocketSession> {
230+
const socketPath = daemonSocketPath();
231+
debugCli(`Connecting to daemon at ${socketPath}`);
232+
233+
if (await socketExists(socketPath)) {
234+
debugCli(`Socket file exists, attempting to connect...`);
235+
try {
236+
return await connectToSocket(socketPath);
237+
} catch (e) {
238+
// Connection failed, delete the stale socket file.
239+
fs.unlinkSync(socketPath);
240+
}
241+
}
242+
243+
const cliPath = path.join(__dirname, '../../../cli.js');
244+
debugCli(`Will launch daemon process: ${cliPath}`);
245+
const child = spawn(process.execPath, [cliPath, 'run-mcp-server', `--daemon=${socketPath}`], {
246+
detached: true,
247+
stdio: 'ignore',
248+
});
249+
child.unref();
250+
251+
// Wait for the socket to become available with retries.
252+
const maxRetries = 50;
253+
const retryDelay = 100; // ms
254+
for (let i = 0; i < maxRetries; i++) {
255+
await new Promise(resolve => setTimeout(resolve, 100));
256+
try {
257+
return await connectToSocket(socketPath);
258+
} catch (e) {
259+
if (e.code !== 'ENOENT')
260+
throw e;
261+
debugCli(`Retrying to connect to daemon at ${socketPath} (${i + 1}/${maxRetries})`);
262+
}
263+
}
264+
throw new Error(`Failed to connect to daemon at ${socketPath} after ${maxRetries * retryDelay}ms`);
265+
}
266+
267+
async function connectToSocket(socketPath: string): Promise<SocketSession> {
268+
const socket = await new Promise<net.Socket>((resolve, reject) => {
269+
const socket = net.createConnection(socketPath, () => {
270+
debugCli(`Connected to daemon at ${socketPath}`);
271+
resolve(socket);
272+
});
273+
socket.on('error', reject);
274+
});
275+
return new SocketSession(new SocketConnection(socket));
276+
}
277+
278+
void program.parseAsync(process.argv);

0 commit comments

Comments
 (0)