@playwright/mcp 0.0.34 → 0.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,6 +56,21 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
56
56
 
57
57
  </details>
58
58
 
59
+ <details>
60
+ <summary>Codex</summary>
61
+
62
+ Create or edit the configuration file `~/.codex/config.toml` and add:
63
+
64
+ ```toml
65
+ [mcp_servers.playwright]
66
+ command = "npx"
67
+ args = ["@playwright/mcp@latest"]
68
+ ```
69
+
70
+ For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
71
+
72
+ </details>
73
+
59
74
  <details>
60
75
  <summary>Cursor</summary>
61
76
 
@@ -479,6 +494,15 @@ http.createServer(async (req, res) => {
479
494
 
480
495
  <!-- NOTE: This has been generated via update-readme.js -->
481
496
 
497
+ - **browser_fill_form**
498
+ - Title: Fill form
499
+ - Description: Fill multiple form fields
500
+ - Parameters:
501
+ - `fields` (array): Fields to fill in
502
+ - Read-only: **false**
503
+
504
+ <!-- NOTE: This has been generated via update-readme.js -->
505
+
482
506
  - **browser_handle_dialog**
483
507
  - Title: Handle a dialog
484
508
  - Description: Handle a dialog
@@ -516,14 +540,6 @@ http.createServer(async (req, res) => {
516
540
 
517
541
  <!-- NOTE: This has been generated via update-readme.js -->
518
542
 
519
- - **browser_navigate_forward**
520
- - Title: Go forward
521
- - Description: Go forward to the next page
522
- - Parameters: None
523
- - Read-only: **true**
524
-
525
- <!-- NOTE: This has been generated via update-readme.js -->
526
-
527
543
  - **browser_network_requests**
528
544
  - Title: List network requests
529
545
  - Description: Returns all network requests since loading the page
@@ -612,39 +628,14 @@ http.createServer(async (req, res) => {
612
628
 
613
629
  <!-- NOTE: This has been generated via update-readme.js -->
614
630
 
615
- - **browser_tab_close**
616
- - Title: Close a tab
617
- - Description: Close a tab
631
+ - **browser_tabs**
632
+ - Title: Manage tabs
633
+ - Description: List, create, close, or select a browser tab.
618
634
  - Parameters:
619
- - `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
635
+ - `action` (string): Operation to perform
636
+ - `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
620
637
  - Read-only: **false**
621
638
 
622
- <!-- NOTE: This has been generated via update-readme.js -->
623
-
624
- - **browser_tab_list**
625
- - Title: List tabs
626
- - Description: List browser tabs
627
- - Parameters: None
628
- - Read-only: **true**
629
-
630
- <!-- NOTE: This has been generated via update-readme.js -->
631
-
632
- - **browser_tab_new**
633
- - Title: Open a new tab
634
- - Description: Open a new tab
635
- - Parameters:
636
- - `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
637
- - Read-only: **true**
638
-
639
- <!-- NOTE: This has been generated via update-readme.js -->
640
-
641
- - **browser_tab_select**
642
- - Title: Select a tab
643
- - Description: Select a tab by index
644
- - Parameters:
645
- - `index` (number): The index of the tab to select
646
- - Read-only: **true**
647
-
648
639
  </details>
649
640
 
650
641
  <details>
@@ -34,19 +34,17 @@ export function contextFactory(config) {
34
34
  return new PersistentContextFactory(config);
35
35
  }
36
36
  class BaseContextFactory {
37
- name;
38
- description;
39
37
  config;
38
+ _logName;
40
39
  _browserPromise;
41
- constructor(name, description, config) {
42
- this.name = name;
43
- this.description = description;
40
+ constructor(name, config) {
41
+ this._logName = name;
44
42
  this.config = config;
45
43
  }
46
44
  async _obtainBrowser(clientInfo) {
47
45
  if (this._browserPromise)
48
46
  return this._browserPromise;
49
- testDebug(`obtain browser (${this.name})`);
47
+ testDebug(`obtain browser (${this._logName})`);
50
48
  this._browserPromise = this._doObtainBrowser(clientInfo);
51
49
  void this._browserPromise.then(browser => {
52
50
  browser.on('disconnected', () => {
@@ -61,7 +59,7 @@ class BaseContextFactory {
61
59
  throw new Error('Not implemented');
62
60
  }
63
61
  async createContext(clientInfo) {
64
- testDebug(`create browser context (${this.name})`);
62
+ testDebug(`create browser context (${this._logName})`);
65
63
  const browser = await this._obtainBrowser(clientInfo);
66
64
  const browserContext = await this._doCreateContext(browser);
67
65
  return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
@@ -70,19 +68,19 @@ class BaseContextFactory {
70
68
  throw new Error('Not implemented');
71
69
  }
72
70
  async _closeBrowserContext(browserContext, browser) {
73
- testDebug(`close browser context (${this.name})`);
71
+ testDebug(`close browser context (${this._logName})`);
74
72
  if (browser.contexts().length === 1)
75
73
  this._browserPromise = undefined;
76
74
  await browserContext.close().catch(logUnhandledError);
77
75
  if (browser.contexts().length === 0) {
78
- testDebug(`close browser (${this.name})`);
76
+ testDebug(`close browser (${this._logName})`);
79
77
  await browser.close().catch(logUnhandledError);
80
78
  }
81
79
  }
82
80
  }
83
81
  class IsolatedContextFactory extends BaseContextFactory {
84
82
  constructor(config) {
85
- super('isolated', 'Create a new isolated browser context', config);
83
+ super('isolated', config);
86
84
  }
87
85
  async _doObtainBrowser(clientInfo) {
88
86
  await injectCdpPort(this.config.browser);
@@ -104,7 +102,7 @@ class IsolatedContextFactory extends BaseContextFactory {
104
102
  }
105
103
  class CdpContextFactory extends BaseContextFactory {
106
104
  constructor(config) {
107
- super('cdp', 'Connect to a browser over CDP', config);
105
+ super('cdp', config);
108
106
  }
109
107
  async _doObtainBrowser() {
110
108
  return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
@@ -115,7 +113,7 @@ class CdpContextFactory extends BaseContextFactory {
115
113
  }
116
114
  class RemoteContextFactory extends BaseContextFactory {
117
115
  constructor(config) {
118
- super('remote', 'Connect to a browser using a remote endpoint', config);
116
+ super('remote', config);
119
117
  }
120
118
  async _doObtainBrowser() {
121
119
  const url = new URL(this.config.browser.remoteEndpoint);
@@ -19,11 +19,8 @@ import { logUnhandledError } from './utils/log.js';
19
19
  import { Response } from './response.js';
20
20
  import { SessionLog } from './sessionLog.js';
21
21
  import { filteredTools } from './tools.js';
22
- import { packageJSON } from './utils/package.js';
23
22
  import { toMcpTool } from './mcp/tool.js';
24
23
  export class BrowserServerBackend {
25
- name = 'Playwright';
26
- version = packageJSON.version;
27
24
  _tools;
28
25
  _context;
29
26
  _sessionLog;
@@ -34,7 +31,7 @@ export class BrowserServerBackend {
34
31
  this._browserContextFactory = factory;
35
32
  this._tools = filteredTools(config);
36
33
  }
37
- async initialize(clientVersion, roots) {
34
+ async initialize(server, clientVersion, roots) {
38
35
  let rootPath;
39
36
  if (roots.length > 0) {
40
37
  const firstRootUri = roots[0]?.uri;
@@ -60,7 +57,7 @@ export class BrowserServerBackend {
60
57
  const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
61
58
  const context = this._context;
62
59
  const response = new Response(context, name, parsedArguments);
63
- context.setRunningTool(true);
60
+ context.setRunningTool(name);
64
61
  try {
65
62
  await tool.handle(context, parsedArguments, response);
66
63
  await response.finish();
@@ -70,7 +67,7 @@ export class BrowserServerBackend {
70
67
  response.addError(String(error));
71
68
  }
72
69
  finally {
73
- context.setRunningTool(false);
70
+ context.setRunningTool(undefined);
74
71
  }
75
72
  return response.serialize();
76
73
  }
package/lib/context.js CHANGED
@@ -30,7 +30,7 @@ export class Context {
30
30
  _clientInfo;
31
31
  static _allContexts = new Set();
32
32
  _closeBrowserContextPromise;
33
- _isRunningTool = false;
33
+ _runningToolName;
34
34
  _abortController = new AbortController();
35
35
  constructor(options) {
36
36
  this.tools = options.tools;
@@ -110,10 +110,10 @@ export class Context {
110
110
  this._closeBrowserContextPromise = undefined;
111
111
  }
112
112
  isRunningTool() {
113
- return this._isRunningTool;
113
+ return this._runningToolName !== undefined;
114
114
  }
115
- setRunningTool(isRunningTool) {
116
- this._isRunningTool = isRunningTool;
115
+ setRunningTool(name) {
116
+ this._runningToolName = name;
117
117
  }
118
118
  async _closeBrowserContextImpl() {
119
119
  if (!this._browserContextPromise)
@@ -156,7 +156,7 @@ export class Context {
156
156
  if (this._closeBrowserContextPromise)
157
157
  throw new Error('Another browser context is being closed.');
158
158
  // TODO: move to the browser context factory to make it based on isolation mode.
159
- const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
159
+ const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
160
160
  const { browserContext } = result;
161
161
  await this._setupRequestInterception(browserContext);
162
162
  if (this.sessionLog)
@@ -23,9 +23,9 @@
23
23
  import { spawn } from 'child_process';
24
24
  import debug from 'debug';
25
25
  import { WebSocket, WebSocketServer } from 'ws';
26
- import { httpAddressToString } from '../utils/httpServer.js';
26
+ import { httpAddressToString } from '../mcp/http.js';
27
27
  import { logUnhandledError } from '../utils/log.js';
28
- import { ManualPromise } from '../utils/manualPromise.js';
28
+ import { ManualPromise } from '../mcp/manualPromise.js';
29
29
  // @ts-ignore
30
30
  const { registry } = await import('playwright-core/lib/server/registry/index');
31
31
  const debugLogger = debug('pw:mcp:relay');
@@ -58,11 +58,11 @@ export class CDPRelayServer {
58
58
  extensionEndpoint() {
59
59
  return `${this._wsHost}${this._extensionPath}`;
60
60
  }
61
- async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal) {
61
+ async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName) {
62
62
  debugLogger('Ensuring extension connection for MCP context');
63
63
  if (this._extensionConnection)
64
64
  return;
65
- this._connectBrowser(clientInfo);
65
+ this._connectBrowser(clientInfo, toolName);
66
66
  debugLogger('Waiting for incoming extension connection');
67
67
  await Promise.race([
68
68
  this._extensionConnectionPromise,
@@ -73,12 +73,18 @@ export class CDPRelayServer {
73
73
  ]);
74
74
  debugLogger('Extension connection established');
75
75
  }
76
- _connectBrowser(clientInfo) {
76
+ _connectBrowser(clientInfo, toolName) {
77
77
  const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
78
78
  // Need to specify "key" in the manifest.json to make the id stable when loading from file.
79
79
  const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
80
80
  url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
81
- url.searchParams.set('client', JSON.stringify(clientInfo));
81
+ const client = {
82
+ name: clientInfo.name,
83
+ version: clientInfo.version,
84
+ };
85
+ url.searchParams.set('client', JSON.stringify(client));
86
+ if (toolName)
87
+ url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
82
88
  const href = url.toString();
83
89
  const executableInfo = registry.findExecutable(this._browserChannel);
84
90
  if (!executableInfo)
@@ -15,20 +15,18 @@
15
15
  */
16
16
  import debug from 'debug';
17
17
  import * as playwright from 'playwright';
18
- import { startHttpServer } from '../utils/httpServer.js';
18
+ import { startHttpServer } from '../mcp/http.js';
19
19
  import { CDPRelayServer } from './cdpRelay.js';
20
20
  const debugLogger = debug('pw:mcp:relay');
21
21
  export class ExtensionContextFactory {
22
- name = 'extension';
23
- description = 'Connect to a browser using the Playwright MCP extension';
24
22
  _browserChannel;
25
23
  _userDataDir;
26
24
  constructor(browserChannel, userDataDir) {
27
25
  this._browserChannel = browserChannel;
28
26
  this._userDataDir = userDataDir;
29
27
  }
30
- async createContext(clientInfo, abortSignal) {
31
- const browser = await this._obtainBrowser(clientInfo, abortSignal);
28
+ async createContext(clientInfo, abortSignal, toolName) {
29
+ const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
32
30
  return {
33
31
  browserContext: browser.contexts()[0],
34
32
  close: async () => {
@@ -37,9 +35,9 @@ export class ExtensionContextFactory {
37
35
  }
38
36
  };
39
37
  }
40
- async _obtainBrowser(clientInfo, abortSignal) {
38
+ async _obtainBrowser(clientInfo, abortSignal, toolName) {
41
39
  const relay = await this._startRelay(abortSignal);
42
- await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
40
+ await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
43
41
  return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
44
42
  }
45
43
  async _startRelay(abortSignal) {
package/lib/index.js CHANGED
@@ -17,10 +17,11 @@ import { BrowserServerBackend } from './browserServerBackend.js';
17
17
  import { resolveConfig } from './config.js';
18
18
  import { contextFactory } from './browserContextFactory.js';
19
19
  import * as mcpServer from './mcp/server.js';
20
+ import { packageJSON } from './utils/package.js';
20
21
  export async function createConnection(userConfig = {}, contextGetter) {
21
22
  const config = await resolveConfig(userConfig);
22
23
  const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
23
- return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
24
+ return mcpServer.createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false);
24
25
  }
25
26
  class SimpleBrowserContextFactory {
26
27
  name = 'custom';
@@ -22,6 +22,7 @@ import { OpenAIDelegate } from '../loop/loopOpenAI.js';
22
22
  import { ClaudeDelegate } from '../loop/loopClaude.js';
23
23
  import { InProcessTransport } from '../mcp/inProcessTransport.js';
24
24
  import * as mcpServer from '../mcp/server.js';
25
+ import { packageJSON } from '../utils/package.js';
25
26
  export class Context {
26
27
  config;
27
28
  _client;
@@ -37,9 +38,9 @@ export class Context {
37
38
  throw new Error('No LLM API key found. Please set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
38
39
  }
39
40
  static async create(config) {
40
- const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
41
+ const client = new Client({ name: 'Playwright Proxy', version: packageJSON.version });
41
42
  const browserContextFactory = contextFactory(config);
42
- const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
43
+ const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
43
44
  await client.connect(new InProcessTransport(server));
44
45
  await client.ping();
45
46
  return new Context(config, client);
@@ -14,7 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import dotenv from 'dotenv';
17
- import * as mcpTransport from '../mcp/transport.js';
17
+ import * as mcpServer from '../mcp/server.js';
18
18
  import { packageJSON } from '../utils/package.js';
19
19
  import { Context } from './context.js';
20
20
  import { perform } from './perform.js';
@@ -22,12 +22,15 @@ import { snapshot } from './snapshot.js';
22
22
  import { toMcpTool } from '../mcp/tool.js';
23
23
  export async function runLoopTools(config) {
24
24
  dotenv.config();
25
- const serverBackendFactory = () => new LoopToolsServerBackend(config);
26
- await mcpTransport.start(serverBackendFactory, config.server);
25
+ const serverBackendFactory = {
26
+ name: 'Playwright',
27
+ nameInConfig: 'playwright-loop',
28
+ version: packageJSON.version,
29
+ create: () => new LoopToolsServerBackend(config)
30
+ };
31
+ await mcpServer.start(serverBackendFactory, config.server);
27
32
  }
28
33
  class LoopToolsServerBackend {
29
- name = 'Playwright';
30
- version = packageJSON.version;
31
34
  _config;
32
35
  _context;
33
36
  _tools = [perform, snapshot];
@@ -13,26 +13,52 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
+ import assert from 'assert';
17
+ import http from 'http';
16
18
  import crypto from 'crypto';
17
19
  import debug from 'debug';
18
20
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
19
21
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
20
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
- import { httpAddressToString, startHttpServer } from '../utils/httpServer.js';
22
22
  import * as mcpServer from './server.js';
23
- export async function start(serverBackendFactory, options) {
24
- if (options.port !== undefined) {
25
- const httpServer = await startHttpServer(options);
26
- startHttpTransport(httpServer, serverBackendFactory);
27
- }
28
- else {
29
- await startStdioTransport(serverBackendFactory);
30
- }
23
+ const testDebug = debug('pw:mcp:test');
24
+ export async function startHttpServer(config, abortSignal) {
25
+ const { host, port } = config;
26
+ const httpServer = http.createServer();
27
+ decorateServer(httpServer);
28
+ await new Promise((resolve, reject) => {
29
+ httpServer.on('error', reject);
30
+ abortSignal?.addEventListener('abort', () => {
31
+ httpServer.close();
32
+ reject(new Error('Aborted'));
33
+ });
34
+ httpServer.listen(port, host, () => {
35
+ resolve();
36
+ httpServer.removeListener('error', reject);
37
+ });
38
+ });
39
+ return httpServer;
31
40
  }
32
- async function startStdioTransport(serverBackendFactory) {
33
- await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
41
+ export function httpAddressToString(address) {
42
+ assert(address, 'Could not bind server socket');
43
+ if (typeof address === 'string')
44
+ return address;
45
+ const resolvedPort = address.port;
46
+ let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
47
+ if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
48
+ resolvedHost = 'localhost';
49
+ return `http://${resolvedHost}:${resolvedPort}`;
50
+ }
51
+ export async function installHttpTransport(httpServer, serverBackendFactory) {
52
+ const sseSessions = new Map();
53
+ const streamableSessions = new Map();
54
+ httpServer.on('request', async (req, res) => {
55
+ const url = new URL(`http://localhost${req.url}`);
56
+ if (url.pathname.startsWith('/sse'))
57
+ await handleSSE(serverBackendFactory, req, res, url, sseSessions);
58
+ else
59
+ await handleStreamable(serverBackendFactory, req, res, streamableSessions);
60
+ });
34
61
  }
35
- const testDebug = debug('pw:mcp:test');
36
62
  async function handleSSE(serverBackendFactory, req, res, url, sessions) {
37
63
  if (req.method === 'POST') {
38
64
  const sessionId = url.searchParams.get('sessionId');
@@ -93,29 +119,17 @@ async function handleStreamable(serverBackendFactory, req, res, sessions) {
93
119
  res.statusCode = 400;
94
120
  res.end('Invalid request');
95
121
  }
96
- function startHttpTransport(httpServer, serverBackendFactory) {
97
- const sseSessions = new Map();
98
- const streamableSessions = new Map();
99
- httpServer.on('request', async (req, res) => {
100
- const url = new URL(`http://localhost${req.url}`);
101
- if (url.pathname.startsWith('/sse'))
102
- await handleSSE(serverBackendFactory, req, res, url, sseSessions);
103
- else
104
- await handleStreamable(serverBackendFactory, req, res, streamableSessions);
122
+ function decorateServer(server) {
123
+ const sockets = new Set();
124
+ server.on('connection', socket => {
125
+ sockets.add(socket);
126
+ socket.once('close', () => sockets.delete(socket));
105
127
  });
106
- const url = httpAddressToString(httpServer.address());
107
- const message = [
108
- `Listening on ${url}`,
109
- 'Put this in your client config:',
110
- JSON.stringify({
111
- 'mcpServers': {
112
- 'playwright': {
113
- 'url': `${url}/mcp`
114
- }
115
- }
116
- }, undefined, 2),
117
- 'For legacy SSE transport support, you can use the /sse endpoint instead.',
118
- ].join('\n');
119
- // eslint-disable-next-line no-console
120
- console.error(message);
128
+ const close = server.close;
129
+ server.close = (callback) => {
130
+ for (const socket of sockets)
131
+ socket.destroy();
132
+ sockets.clear();
133
+ return close.call(server, callback);
134
+ };
121
135
  }