This is page 2 of 2. Use http://codebase.md/justasmonkev/mcp-accessibility-scanner?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── .idea
│ ├── .gitignore
│ ├── copilot.data.migration.agent.xml
│ ├── copilot.data.migration.edit.xml
│ ├── modules.xml
│ ├── New folder (3).iml
│ └── vcs.xml
├── cli.js
├── config.d.ts
├── eslint.config.mjs
├── glama.json
├── index.d.ts
├── index.js
├── LICENSE
├── NOTICE.md
├── package-lock.json
├── package.json
├── README.md
├── server.json
├── src
│ ├── actions.d.ts
│ ├── browserContextFactory.ts
│ ├── browserServerBackend.ts
│ ├── config.ts
│ ├── context.ts
│ ├── DEPS.list
│ ├── extension
│ │ ├── cdpRelay.ts
│ │ ├── DEPS.list
│ │ ├── extensionContextFactory.ts
│ │ └── protocol.ts
│ ├── external-modules.d.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── DEPS.list
│ │ ├── http.ts
│ │ ├── inProcessTransport.ts
│ │ ├── manualPromise.ts
│ │ ├── mdb.ts
│ │ ├── proxyBackend.ts
│ │ ├── README.md
│ │ ├── server.ts
│ │ └── tool.ts
│ ├── program.ts
│ ├── response.ts
│ ├── sessionLog.ts
│ ├── tab.ts
│ ├── tools
│ │ ├── common.ts
│ │ ├── console.ts
│ │ ├── DEPS.list
│ │ ├── dialogs.ts
│ │ ├── evaluate.ts
│ │ ├── files.ts
│ │ ├── form.ts
│ │ ├── install.ts
│ │ ├── keyboard.ts
│ │ ├── mouse.ts
│ │ ├── navigate.ts
│ │ ├── network.ts
│ │ ├── pdf.ts
│ │ ├── screenshot.ts
│ │ ├── snapshot.ts
│ │ ├── tabs.ts
│ │ ├── tool.ts
│ │ ├── utils.ts
│ │ ├── verify.ts
│ │ └── wait.ts
│ ├── tools.ts
│ ├── utils
│ │ ├── codegen.ts
│ │ ├── fileUtils.ts
│ │ ├── guid.ts
│ │ ├── log.ts
│ │ └── package.ts
│ └── vscode
│ ├── DEPS.list
│ ├── host.ts
│ └── main.ts
├── tsconfig.all.json
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/tools/snapshot.ts:
--------------------------------------------------------------------------------
```typescript
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 | import {z} from 'zod';
18 | import {defineTabTool, defineTool} from './tool.js';
19 | import * as javascript from '../utils/codegen.js';
20 | import {generateLocator} from './utils.js';
21 | import AxeBuilder from "@axe-core/playwright";
22 |
23 |
24 | const tagValues = [
25 | 'wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa',
26 | 'wcag22a', 'wcag22aa', 'wcag22aaa', 'section508', 'cat.aria', 'cat.color',
27 | 'cat.forms', 'cat.keyboard', 'cat.language', 'cat.name-role-value',
28 | 'cat.parsing', 'cat.semantics', 'cat.sensory-and-visual-cues',
29 | 'cat.structure', 'cat.tables', 'cat.text-alternatives', 'cat.time-and-media',
30 | ] as const;
31 |
32 |
33 | const scanPageSchema = z.object({
34 | violationsTag: z
35 | .array(z.enum(tagValues))
36 | .min(1)
37 | .default([...tagValues])
38 | .describe('Array of tags to filter violations by. If not specified, all violations are returned.')
39 | });
40 |
41 | type AxeScanResult = Awaited<ReturnType<InstanceType<typeof AxeBuilder>['analyze']>>;
42 | type AxeViolation = AxeScanResult['violations'][number];
43 | type AxeNode = AxeViolation['nodes'][number];
44 |
45 | const dedupeViolationNodes = (nodes: AxeNode[]): AxeNode[] => {
46 | const seen = new Set<string>();
47 | return nodes.filter((node) => {
48 | const key = JSON.stringify({target: node.target ?? [], html: node.html ?? ''});
49 | if (seen.has(key))
50 | return false;
51 |
52 | seen.add(key);
53 | return true;
54 | });
55 | };
56 |
57 | const scanPage = defineTool({
58 | capability: 'core',
59 | schema: {
60 | name: 'scan_page',
61 | title: 'Scan page for accessibility violations',
62 | description: 'Scan the current page for accessibility violations using Axe',
63 | inputSchema: scanPageSchema,
64 | type: 'destructive',
65 | },
66 |
67 | handle: async (context, params, response) => {
68 | const tab = context.currentTabOrDie();
69 | const axe = new AxeBuilder({page: tab.page}).withTags(params.violationsTag);
70 |
71 | const results = await axe.analyze();
72 |
73 | response.addResult([
74 | `URL: ${results.url}`,
75 | '',
76 | `Violations: ${results.violations.length}, Incomplete: ${results.incomplete.length}, Passes: ${results.passes.length}, Inapplicable: ${results.inapplicable.length}`,
77 | ].join('\n'));
78 |
79 |
80 | results.violations.forEach((violation) => {
81 | const uniqueNodes = dedupeViolationNodes(violation.nodes);
82 |
83 | response.addResult([
84 | '',
85 | `Tags : ${violation.tags}`,
86 | `Violations: ${JSON.stringify(uniqueNodes, null, 2)}`,
87 | ].join('\n'));
88 | });
89 | },
90 | });
91 |
92 | const snapshot = defineTool({
93 | capability: 'core',
94 | schema: {
95 | name: 'browser_snapshot',
96 | title: 'Page snapshot',
97 | description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
98 | inputSchema: z.object({}),
99 | type: 'readOnly',
100 | },
101 |
102 | handle: async (context, params, response) => {
103 | await context.ensureTab();
104 | response.setIncludeSnapshot();
105 | },
106 | });
107 |
108 | export const elementSchema = z.object({
109 | element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
110 | ref: z.string().describe('Exact target element reference from the page snapshot'),
111 | });
112 |
113 | const clickSchema = elementSchema.extend({
114 | doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
115 | button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
116 | });
117 |
118 | const click = defineTabTool({
119 | capability: 'core',
120 | schema: {
121 | name: 'browser_click',
122 | title: 'Click',
123 | description: 'Perform click on a web page',
124 | inputSchema: clickSchema,
125 | type: 'destructive',
126 | },
127 |
128 | handle: async (tab, params, response) => {
129 | response.setIncludeSnapshot();
130 |
131 | const locator = await tab.refLocator(params);
132 | const button = params.button;
133 | const buttonAttr = button ? `{ button: '${button}' }` : '';
134 |
135 | if (params.doubleClick)
136 | response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
137 | else
138 | response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
139 |
140 |
141 | await tab.waitForCompletion(async () => {
142 | if (params.doubleClick)
143 | await locator.dblclick({button});
144 | else
145 | await locator.click({button});
146 | });
147 | },
148 | });
149 |
150 | const drag = defineTabTool({
151 | capability: 'core',
152 | schema: {
153 | name: 'browser_drag',
154 | title: 'Drag mouse',
155 | description: 'Perform drag and drop between two elements',
156 | inputSchema: z.object({
157 | startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
158 | startRef: z.string().describe('Exact source element reference from the page snapshot'),
159 | endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
160 | endRef: z.string().describe('Exact target element reference from the page snapshot'),
161 | }),
162 | type: 'destructive',
163 | },
164 |
165 | handle: async (tab, params, response) => {
166 | response.setIncludeSnapshot();
167 |
168 | const [startLocator, endLocator] = await tab.refLocators([
169 | {ref: params.startRef, element: params.startElement},
170 | {ref: params.endRef, element: params.endElement},
171 | ]);
172 |
173 | await tab.waitForCompletion(async () => {
174 | await startLocator.dragTo(endLocator);
175 | });
176 |
177 | response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
178 | },
179 | });
180 |
181 | const hover = defineTabTool({
182 | capability: 'core',
183 | schema: {
184 | name: 'browser_hover',
185 | title: 'Hover mouse',
186 | description: 'Hover over element on page',
187 | inputSchema: elementSchema,
188 | type: 'readOnly',
189 | },
190 |
191 | handle: async (tab, params, response) => {
192 | response.setIncludeSnapshot();
193 |
194 | const locator = await tab.refLocator(params);
195 | response.addCode(`await page.${await generateLocator(locator)}.hover();`);
196 |
197 | await tab.waitForCompletion(async () => {
198 | await locator.hover();
199 | });
200 | },
201 | });
202 |
203 | const selectOptionSchema = elementSchema.extend({
204 | values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
205 | });
206 |
207 | const selectOption = defineTabTool({
208 | capability: 'core',
209 | schema: {
210 | name: 'browser_select_option',
211 | title: 'Select option',
212 | description: 'Select an option in a dropdown',
213 | inputSchema: selectOptionSchema,
214 | type: 'destructive',
215 | },
216 |
217 | handle: async (tab, params, response) => {
218 | response.setIncludeSnapshot();
219 |
220 | const locator = await tab.refLocator(params);
221 | response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
222 |
223 | await tab.waitForCompletion(async () => {
224 | await locator.selectOption(params.values);
225 | });
226 | },
227 | });
228 |
229 | export default [
230 | snapshot,
231 | click,
232 | drag,
233 | hover,
234 | selectOption,
235 | scanPage
236 | ];
237 |
```
--------------------------------------------------------------------------------
/src/mcp/mdb.ts:
--------------------------------------------------------------------------------
```typescript
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 | import debug from 'debug';
18 | import { z } from 'zod';
19 |
20 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
21 | import { PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
22 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
23 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
24 |
25 | import { defineToolSchema } from './tool.js';
26 | import * as mcpServer from './server.js';
27 | import * as mcpHttp from './http.js';
28 | import { wrapInProcess } from './server.js';
29 | import { ManualPromise } from './manualPromise.js';
30 |
31 | import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
32 |
33 | const mdbDebug = debug('pw:mcp:mdb');
34 | const errorsDebug = debug('pw:mcp:errors');
35 |
36 | export class MDBBackend implements mcpServer.ServerBackend {
37 | private _stack: { client: Client, toolNames: string[], resultPromise: ManualPromise<mcpServer.CallToolResult> | undefined }[] = [];
38 | private _interruptPromise: ManualPromise<mcpServer.CallToolResult> | undefined;
39 | private _topLevelBackend: mcpServer.ServerBackend;
40 | private _initialized = false;
41 |
42 | constructor(topLevelBackend: mcpServer.ServerBackend) {
43 | this._topLevelBackend = topLevelBackend;
44 | }
45 |
46 | async initialize(server: mcpServer.Server): Promise<void> {
47 | if (this._initialized)
48 | return;
49 | this._initialized = true;
50 | const transport = await wrapInProcess(this._topLevelBackend);
51 | await this._pushClient(transport);
52 | }
53 |
54 | async listTools(): Promise<mcpServer.Tool[]> {
55 | const response = await this._client().listTools();
56 | return response.tools;
57 | }
58 |
59 | async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
60 | if (name === pushToolsSchema.name)
61 | return await this._pushTools(pushToolsSchema.inputSchema.parse(args || {}));
62 |
63 | const interruptPromise = new ManualPromise<mcpServer.CallToolResult>();
64 | this._interruptPromise = interruptPromise;
65 | let [entry] = this._stack;
66 |
67 | // Pop the client while the tool is not found.
68 | while (entry && !entry.toolNames.includes(name)) {
69 | mdbDebug('popping client from stack for ', name);
70 | this._stack.shift();
71 | await entry.client.close();
72 | entry = this._stack[0];
73 | }
74 | if (!entry)
75 | throw new Error(`Tool ${name} not found in the tool stack`);
76 |
77 | const resultPromise = new ManualPromise<mcpServer.CallToolResult>();
78 | entry.resultPromise = resultPromise;
79 |
80 | this._client().callTool({
81 | name,
82 | arguments: args,
83 | }).then(result => {
84 | resultPromise.resolve(result as mcpServer.CallToolResult);
85 | }).catch(e => {
86 | mdbDebug('error in client call', e);
87 | if (this._stack.length < 2)
88 | throw e;
89 | this._stack.shift();
90 | const prevEntry = this._stack[0];
91 | void prevEntry.resultPromise!.then(result => resultPromise.resolve(result));
92 | });
93 | const result = await Promise.race([interruptPromise, resultPromise]);
94 | if (interruptPromise.isDone())
95 | mdbDebug('client call intercepted', result);
96 | else
97 | mdbDebug('client call result', result);
98 | return result;
99 | }
100 |
101 | private _client(): Client {
102 | const [entry] = this._stack;
103 | if (!entry)
104 | throw new Error('No debugging backend available');
105 | return entry.client;
106 | }
107 |
108 | private async _pushTools(params: { mcpUrl: string, introMessage?: string }): Promise<mcpServer.CallToolResult> {
109 | mdbDebug('pushing tools to the stack', params.mcpUrl);
110 | const transport = new StreamableHTTPClientTransport(new URL(params.mcpUrl));
111 | await this._pushClient(transport, params.introMessage);
112 | return { content: [{ type: 'text', text: 'Tools pushed' }] };
113 | }
114 |
115 | private async _pushClient(transport: Transport, introMessage?: string): Promise<mcpServer.CallToolResult> {
116 | mdbDebug('pushing client to the stack');
117 | const client = new Client({ name: 'Internal client', version: '0.0.0' });
118 | client.setRequestHandler(PingRequestSchema, () => ({}));
119 | await client.connect(transport);
120 | mdbDebug('connected to the new client');
121 | const { tools } = await client.listTools();
122 | this._stack.unshift({ client, toolNames: tools.map(tool => tool.name), resultPromise: undefined });
123 | mdbDebug('new tools added to the stack:', tools.map(tool => tool.name));
124 | mdbDebug('interrupting current call:', !!this._interruptPromise);
125 | this._interruptPromise?.resolve({
126 | content: [{
127 | type: 'text',
128 | text: introMessage || '',
129 | }],
130 | });
131 | this._interruptPromise = undefined;
132 | return { content: [{ type: 'text', text: 'Tools pushed' }] };
133 | }
134 | }
135 |
136 | const pushToolsSchema = defineToolSchema({
137 | name: 'mdb_push_tools',
138 | title: 'Push MCP tools to the tools stack',
139 | description: 'Push MCP tools to the tools stack',
140 | inputSchema: z.object({
141 | mcpUrl: z.string(),
142 | introMessage: z.string().optional(),
143 | }),
144 | type: 'readOnly',
145 | });
146 |
147 | export type ServerBackendOnPause = mcpServer.ServerBackend & {
148 | requestSelfDestruct?: () => void;
149 | };
150 |
151 | export async function runMainBackend(backendFactory: mcpServer.ServerBackendFactory, options?: { port?: number }): Promise<string | undefined> {
152 | const mdbBackend = new MDBBackend(backendFactory.create());
153 | // Start HTTP unconditionally.
154 | const factory: mcpServer.ServerBackendFactory = {
155 | ...backendFactory,
156 | create: () => mdbBackend
157 | };
158 | const url = await startAsHttp(factory, { port: options?.port || 0 });
159 | process.env.PLAYWRIGHT_DEBUGGER_MCP = url;
160 |
161 | if (options?.port !== undefined)
162 | return url;
163 |
164 | // Start stdio conditionally.
165 | await mcpServer.connect(factory, new StdioServerTransport(), false);
166 | }
167 |
168 | export async function runOnPauseBackendLoop(mdbUrl: string, backend: ServerBackendOnPause, introMessage: string) {
169 | const wrappedBackend = new OnceTimeServerBackendWrapper(backend);
170 |
171 | const factory = {
172 | name: 'on-pause-backend',
173 | nameInConfig: 'on-pause-backend',
174 | version: '0.0.0',
175 | create: () => wrappedBackend,
176 | };
177 |
178 | const httpServer = await mcpHttp.startHttpServer({ port: 0 });
179 | await mcpHttp.installHttpTransport(httpServer, factory);
180 | const url = mcpHttp.httpAddressToString(httpServer.address());
181 |
182 | const client = new Client({ name: 'Internal client', version: '0.0.0' });
183 | client.setRequestHandler(PingRequestSchema, () => ({}));
184 | const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
185 | await client.connect(transport);
186 |
187 | const pushToolsResult = await client.callTool({
188 | name: pushToolsSchema.name,
189 | arguments: {
190 | mcpUrl: url,
191 | introMessage,
192 | },
193 | });
194 | if (pushToolsResult.isError)
195 | errorsDebug('Failed to push tools', pushToolsResult.content);
196 | await transport.terminateSession();
197 | await client.close();
198 |
199 | await wrappedBackend.waitForClosed();
200 | httpServer.close();
201 | }
202 |
203 | async function startAsHttp(backendFactory: mcpServer.ServerBackendFactory, options: { port: number }) {
204 | const httpServer = await mcpHttp.startHttpServer(options);
205 | await mcpHttp.installHttpTransport(httpServer, backendFactory);
206 | return mcpHttp.httpAddressToString(httpServer.address());
207 | }
208 |
209 |
210 | class OnceTimeServerBackendWrapper implements mcpServer.ServerBackend {
211 | private _backend: ServerBackendOnPause;
212 | private _selfDestructPromise = new ManualPromise<void>();
213 |
214 | constructor(backend: ServerBackendOnPause) {
215 | this._backend = backend;
216 | this._backend.requestSelfDestruct = () => this._selfDestructPromise.resolve();
217 | }
218 |
219 | async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
220 | await this._backend.initialize?.(server, clientVersion, roots);
221 | }
222 |
223 | async listTools(): Promise<mcpServer.Tool[]> {
224 | return this._backend.listTools();
225 | }
226 |
227 | async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
228 | return this._backend.callTool(name, args);
229 | }
230 |
231 | serverClosed(server: mcpServer.Server) {
232 | this._backend.serverClosed?.(server);
233 | this._selfDestructPromise.resolve();
234 | }
235 |
236 | async waitForClosed() {
237 | await this._selfDestructPromise;
238 | }
239 | }
240 |
```
--------------------------------------------------------------------------------
/src/context.ts:
--------------------------------------------------------------------------------
```typescript
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 | import debug from 'debug';
18 | import * as playwright from 'playwright';
19 |
20 | import { logUnhandledError } from './utils/log.js';
21 | import { Tab } from './tab.js';
22 | import { outputFile } from './config.js';
23 |
24 | import type { FullConfig } from './config.js';
25 | import type { Tool } from './tools/tool.js';
26 | import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js';
27 | import type * as actions from './actions.js';
28 | import type { SessionLog } from './sessionLog.js';
29 |
30 | const testDebug = debug('pw:mcp:test');
31 |
32 | type ContextOptions = {
33 | tools: Tool[];
34 | config: FullConfig;
35 | browserContextFactory: BrowserContextFactory;
36 | sessionLog: SessionLog | undefined;
37 | clientInfo: ClientInfo;
38 | };
39 |
40 | export class Context {
41 | readonly tools: Tool[];
42 | readonly config: FullConfig;
43 | readonly sessionLog: SessionLog | undefined;
44 | readonly options: ContextOptions;
45 | private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
46 | private _browserContextFactory: BrowserContextFactory;
47 | private _tabs: Tab[] = [];
48 | private _currentTab: Tab | undefined;
49 | private _clientInfo: ClientInfo;
50 |
51 | private static _allContexts: Set<Context> = new Set();
52 | private _closeBrowserContextPromise: Promise<void> | undefined;
53 | private _runningToolName: string | undefined;
54 | private _abortController = new AbortController();
55 |
56 | constructor(options: ContextOptions) {
57 | this.tools = options.tools;
58 | this.config = options.config;
59 | this.sessionLog = options.sessionLog;
60 | this.options = options;
61 | this._browserContextFactory = options.browserContextFactory;
62 | this._clientInfo = options.clientInfo;
63 | testDebug('create context');
64 | Context._allContexts.add(this);
65 | }
66 |
67 | static async disposeAll() {
68 | await Promise.all([...Context._allContexts].map(context => context.dispose()));
69 | }
70 |
71 | tabs(): Tab[] {
72 | return this._tabs;
73 | }
74 |
75 | currentTab(): Tab | undefined {
76 | return this._currentTab;
77 | }
78 |
79 | currentTabOrDie(): Tab {
80 | if (!this._currentTab)
81 | throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
82 | return this._currentTab;
83 | }
84 |
85 | async newTab(): Promise<Tab> {
86 | const { browserContext } = await this._ensureBrowserContext();
87 | const page = await browserContext.newPage();
88 | this._currentTab = this._tabs.find(t => t.page === page)!;
89 | return this._currentTab;
90 | }
91 |
92 | async selectTab(index: number) {
93 | const tab = this._tabs[index];
94 | if (!tab)
95 | throw new Error(`Tab ${index} not found`);
96 | await tab.page.bringToFront();
97 | this._currentTab = tab;
98 | return tab;
99 | }
100 |
101 | async ensureTab(): Promise<Tab> {
102 | const { browserContext } = await this._ensureBrowserContext();
103 | if (!this._currentTab)
104 | await browserContext.newPage();
105 | return this._currentTab!;
106 | }
107 |
108 | async closeTab(index: number | undefined): Promise<string> {
109 | const tab = index === undefined ? this._currentTab : this._tabs[index];
110 | if (!tab)
111 | throw new Error(`Tab ${index} not found`);
112 | const url = tab.page.url();
113 | await tab.page.close();
114 | return url;
115 | }
116 |
117 | async outputFile(name: string): Promise<string> {
118 | return outputFile(this.config, this._clientInfo.rootPath, name);
119 | }
120 |
121 | private _onPageCreated(page: playwright.Page) {
122 | const tab = new Tab(this, page, tab => this._onPageClosed(tab));
123 | this._tabs.push(tab);
124 | if (!this._currentTab)
125 | this._currentTab = tab;
126 | }
127 |
128 | private _onPageClosed(tab: Tab) {
129 | const index = this._tabs.indexOf(tab);
130 | if (index === -1)
131 | return;
132 | this._tabs.splice(index, 1);
133 |
134 | if (this._currentTab === tab)
135 | this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
136 | if (!this._tabs.length)
137 | void this.closeBrowserContext();
138 | }
139 |
140 | async closeBrowserContext() {
141 | if (!this._closeBrowserContextPromise)
142 | this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
143 | await this._closeBrowserContextPromise;
144 | this._closeBrowserContextPromise = undefined;
145 | }
146 |
147 | isRunningTool() {
148 | return this._runningToolName !== undefined;
149 | }
150 |
151 | setRunningTool(name: string | undefined) {
152 | this._runningToolName = name;
153 | }
154 |
155 | private async _closeBrowserContextImpl() {
156 | if (!this._browserContextPromise)
157 | return;
158 |
159 | testDebug('close context');
160 |
161 | const promise = this._browserContextPromise;
162 | this._browserContextPromise = undefined;
163 |
164 | await promise.then(async ({ browserContext, close }) => {
165 | if (this.config.saveTrace)
166 | await browserContext.tracing.stop();
167 | await close();
168 | });
169 | }
170 |
171 | async dispose() {
172 | this._abortController.abort('MCP context disposed');
173 | await this.closeBrowserContext();
174 | Context._allContexts.delete(this);
175 | }
176 |
177 | private async _setupRequestInterception(context: playwright.BrowserContext) {
178 | if (this.config.network?.allowedOrigins?.length) {
179 | await context.route('**', route => route.abort('blockedbyclient'));
180 |
181 | for (const origin of this.config.network.allowedOrigins)
182 | await context.route(`*://${origin}/**`, route => route.continue());
183 | }
184 |
185 | if (this.config.network?.blockedOrigins?.length) {
186 | for (const origin of this.config.network.blockedOrigins)
187 | await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
188 | }
189 | }
190 |
191 | private _ensureBrowserContext() {
192 | if (!this._browserContextPromise) {
193 | this._browserContextPromise = this._setupBrowserContext();
194 | this._browserContextPromise.catch(() => {
195 | this._browserContextPromise = undefined;
196 | });
197 | }
198 | return this._browserContextPromise;
199 | }
200 |
201 | private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
202 | if (this._closeBrowserContextPromise)
203 | throw new Error('Another browser context is being closed.');
204 | // TODO: move to the browser context factory to make it based on isolation mode.
205 | const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
206 | const { browserContext } = result;
207 | await this._setupRequestInterception(browserContext);
208 | if (this.sessionLog)
209 | await InputRecorder.create(this, browserContext);
210 | for (const page of browserContext.pages())
211 | this._onPageCreated(page);
212 | browserContext.on('page', page => this._onPageCreated(page));
213 | if (this.config.saveTrace) {
214 | await browserContext.tracing.start({
215 | name: 'trace',
216 | screenshots: false,
217 | snapshots: true,
218 | sources: false,
219 | });
220 | }
221 | return result;
222 | }
223 | }
224 |
225 | export class InputRecorder {
226 | private _context: Context;
227 | private _browserContext: playwright.BrowserContext;
228 |
229 | private constructor(context: Context, browserContext: playwright.BrowserContext) {
230 | this._context = context;
231 | this._browserContext = browserContext;
232 | }
233 |
234 | static async create(context: Context, browserContext: playwright.BrowserContext) {
235 | const recorder = new InputRecorder(context, browserContext);
236 | await recorder._initialize();
237 | return recorder;
238 | }
239 |
240 | private async _initialize() {
241 | const sessionLog = this._context.sessionLog!;
242 | await (this._browserContext as any)._enableRecorder({
243 | mode: 'recording',
244 | recorderMode: 'api',
245 | }, {
246 | actionAdded: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
247 | if (this._context.isRunningTool())
248 | return;
249 | const tab = Tab.forPage(page);
250 | if (tab)
251 | sessionLog.logUserAction(data.action, tab, code, false);
252 | },
253 | actionUpdated: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
254 | if (this._context.isRunningTool())
255 | return;
256 | const tab = Tab.forPage(page);
257 | if (tab)
258 | sessionLog.logUserAction(data.action, tab, code, true);
259 | },
260 | signalAdded: (page: playwright.Page, data: actions.SignalInContext) => {
261 | if (this._context.isRunningTool())
262 | return;
263 | if (data.signal.name !== 'navigation')
264 | return;
265 | const tab = Tab.forPage(page);
266 | const navigateAction: actions.Action = {
267 | name: 'navigate',
268 | url: data.signal.url,
269 | signals: [],
270 | };
271 | if (tab)
272 | sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
273 | },
274 | });
275 | }
276 | }
277 |
```
--------------------------------------------------------------------------------
/src/browserContextFactory.ts:
--------------------------------------------------------------------------------
```typescript
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 | import fs from 'fs';
18 | import net from 'net';
19 | import path from 'path';
20 |
21 | import * as playwright from 'playwright';
22 | // @ts-ignore
23 | import { registryDirectory } from 'playwright-core/lib/server/registry/index';
24 | // @ts-ignore
25 | import { startTraceViewerServer } from 'playwright-core/lib/server';
26 | import { logUnhandledError, testDebug } from './utils/log.js';
27 | import { createHash } from './utils/guid.js';
28 | import { outputFile } from './config.js';
29 |
30 | import type { FullConfig } from './config.js';
31 |
32 | export function contextFactory(config: FullConfig): BrowserContextFactory {
33 | if (config.browser.remoteEndpoint)
34 | return new RemoteContextFactory(config);
35 | if (config.browser.cdpEndpoint)
36 | return new CdpContextFactory(config);
37 | if (config.browser.isolated)
38 | return new IsolatedContextFactory(config);
39 | return new PersistentContextFactory(config);
40 | }
41 |
42 | export type ClientInfo = { name?: string, version?: string, rootPath?: string };
43 |
44 | export interface BrowserContextFactory {
45 | createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
46 | }
47 |
48 | class BaseContextFactory implements BrowserContextFactory {
49 | readonly config: FullConfig;
50 | private _logName: string;
51 | protected _browserPromise: Promise<playwright.Browser> | undefined;
52 |
53 | constructor(name: string, config: FullConfig) {
54 | this._logName = name;
55 | this.config = config;
56 | }
57 |
58 | protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
59 | if (this._browserPromise)
60 | return this._browserPromise;
61 | testDebug(`obtain browser (${this._logName})`);
62 | this._browserPromise = this._doObtainBrowser(clientInfo);
63 | void this._browserPromise.then(browser => {
64 | browser.on('disconnected', () => {
65 | this._browserPromise = undefined;
66 | });
67 | }).catch(() => {
68 | this._browserPromise = undefined;
69 | });
70 | return this._browserPromise;
71 | }
72 |
73 | protected async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
74 | throw new Error('Not implemented');
75 | }
76 |
77 | async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
78 | testDebug(`create browser context (${this._logName})`);
79 | const browser = await this._obtainBrowser(clientInfo);
80 | const browserContext = await this._doCreateContext(browser);
81 | return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
82 | }
83 |
84 | protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
85 | throw new Error('Not implemented');
86 | }
87 |
88 | private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
89 | testDebug(`close browser context (${this._logName})`);
90 | if (browser.contexts().length === 1)
91 | this._browserPromise = undefined;
92 | await browserContext.close().catch(logUnhandledError);
93 | if (browser.contexts().length === 0) {
94 | testDebug(`close browser (${this._logName})`);
95 | await browser.close().catch(logUnhandledError);
96 | }
97 | }
98 | }
99 |
100 | class IsolatedContextFactory extends BaseContextFactory {
101 | constructor(config: FullConfig) {
102 | super('isolated', config);
103 | }
104 |
105 | protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
106 | await injectCdpPort(this.config.browser);
107 | const browserType = playwright[this.config.browser.browserName];
108 | return browserType.launch({
109 | tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
110 | ...this.config.browser.launchOptions,
111 | handleSIGINT: false,
112 | handleSIGTERM: false,
113 | }).catch(error => {
114 | if (error.message.includes('Executable doesn\'t exist'))
115 | throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
116 | throw error;
117 | });
118 | }
119 |
120 | protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
121 | return browser.newContext(this.config.browser.contextOptions);
122 | }
123 | }
124 |
125 | class CdpContextFactory extends BaseContextFactory {
126 | constructor(config: FullConfig) {
127 | super('cdp', config);
128 | }
129 |
130 | protected override async _doObtainBrowser(): Promise<playwright.Browser> {
131 | return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!);
132 | }
133 |
134 | protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
135 | return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
136 | }
137 | }
138 |
139 | class RemoteContextFactory extends BaseContextFactory {
140 | constructor(config: FullConfig) {
141 | super('remote', config);
142 | }
143 |
144 | protected override async _doObtainBrowser(): Promise<playwright.Browser> {
145 | const url = new URL(this.config.browser.remoteEndpoint!);
146 | url.searchParams.set('browser', this.config.browser.browserName);
147 | if (this.config.browser.launchOptions)
148 | url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
149 | return playwright[this.config.browser.browserName].connect(String(url));
150 | }
151 |
152 | protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
153 | return browser.newContext();
154 | }
155 | }
156 |
157 | class PersistentContextFactory implements BrowserContextFactory {
158 | readonly config: FullConfig;
159 | readonly name = 'persistent';
160 | readonly description = 'Create a new persistent browser context';
161 |
162 | private _userDataDirs = new Set<string>();
163 |
164 | constructor(config: FullConfig) {
165 | this.config = config;
166 | }
167 |
168 | async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
169 | await injectCdpPort(this.config.browser);
170 | testDebug('create browser context (persistent)');
171 | const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
172 | const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
173 |
174 | this._userDataDirs.add(userDataDir);
175 | testDebug('lock user data dir', userDataDir);
176 |
177 | const browserType = playwright[this.config.browser.browserName];
178 | for (let i = 0; i < 5; i++) {
179 | try {
180 | const browserContext = await browserType.launchPersistentContext(userDataDir, {
181 | tracesDir,
182 | ...this.config.browser.launchOptions,
183 | ...this.config.browser.contextOptions,
184 | handleSIGINT: false,
185 | handleSIGTERM: false,
186 | });
187 | const close = () => this._closeBrowserContext(browserContext, userDataDir);
188 | return { browserContext, close };
189 | } catch (error: any) {
190 | if (error.message.includes('Executable doesn\'t exist'))
191 | throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
192 | if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
193 | // User data directory is already in use, try again.
194 | await new Promise(resolve => setTimeout(resolve, 1000));
195 | continue;
196 | }
197 | throw error;
198 | }
199 | }
200 | throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
201 | }
202 |
203 | private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
204 | testDebug('close browser context (persistent)');
205 | testDebug('release user data dir', userDataDir);
206 | await browserContext.close().catch(() => {});
207 | this._userDataDirs.delete(userDataDir);
208 | testDebug('close browser context complete (persistent)');
209 | }
210 |
211 | private async _createUserDataDir(rootPath: string | undefined) {
212 | const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
213 | const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
214 | // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
215 | const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
216 | const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
217 | await fs.promises.mkdir(result, { recursive: true });
218 | return result;
219 | }
220 | }
221 |
222 | async function injectCdpPort(browserConfig: FullConfig['browser']) {
223 | if (browserConfig.browserName === 'chromium')
224 | (browserConfig.launchOptions as any).cdpPort = await findFreePort();
225 | }
226 |
227 | async function findFreePort(): Promise<number> {
228 | return new Promise((resolve, reject) => {
229 | const server = net.createServer();
230 | server.listen(0, () => {
231 | const { port } = server.address() as net.AddressInfo;
232 | server.close(() => resolve(port));
233 | });
234 | server.on('error', reject);
235 | });
236 | }
237 |
238 | async function startTraceServer(config: FullConfig, rootPath: string | undefined): Promise<string | undefined> {
239 | if (!config.saveTrace)
240 | return undefined;
241 |
242 | const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
243 | const server = await startTraceViewerServer();
244 | const urlPrefix = server.urlPrefix('human-readable');
245 | const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
246 | // eslint-disable-next-line no-console
247 | console.error('\nTrace viewer listening on ' + url);
248 | return tracesDir;
249 | }
250 |
```
--------------------------------------------------------------------------------
/src/tab.ts:
--------------------------------------------------------------------------------
```typescript
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 | import { EventEmitter } from 'events';
18 | import * as playwright from 'playwright';
19 | import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
20 | import { logUnhandledError } from './utils/log.js';
21 | import { ManualPromise } from './mcp/manualPromise.js';
22 | import { ModalState } from './tools/tool.js';
23 |
24 | import type { Context } from './context.js';
25 |
26 | type PageEx = playwright.Page & {
27 | _snapshotForAI: () => Promise<string>;
28 | };
29 |
30 | export const TabEvents = {
31 | modalState: 'modalState'
32 | };
33 |
34 | export type TabEventsInterface = {
35 | [TabEvents.modalState]: [modalState: ModalState];
36 | };
37 |
38 | export type TabSnapshot = {
39 | url: string;
40 | title: string;
41 | ariaSnapshot: string;
42 | modalStates: ModalState[];
43 | consoleMessages: ConsoleMessage[];
44 | downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
45 | };
46 |
47 | export class Tab extends EventEmitter<TabEventsInterface> {
48 | readonly context: Context;
49 | readonly page: playwright.Page;
50 | private _lastTitle = 'about:blank';
51 | private _consoleMessages: ConsoleMessage[] = [];
52 | private _recentConsoleMessages: ConsoleMessage[] = [];
53 | private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
54 | private _onPageClose: (tab: Tab) => void;
55 | private _modalStates: ModalState[] = [];
56 | private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
57 |
58 | constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
59 | super();
60 | this.context = context;
61 | this.page = page;
62 | this._onPageClose = onPageClose;
63 | page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
64 | page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
65 | page.on('request', request => this._requests.set(request, null));
66 | page.on('response', response => this._requests.set(response.request(), response));
67 | page.on('close', () => this._onClose());
68 | page.on('filechooser', chooser => {
69 | this.setModalState({
70 | type: 'fileChooser',
71 | description: 'File chooser',
72 | fileChooser: chooser,
73 | });
74 | });
75 | page.on('dialog', dialog => this._dialogShown(dialog));
76 | page.on('download', download => {
77 | void this._downloadStarted(download);
78 | });
79 | page.setDefaultNavigationTimeout(context.config.timeouts.navigationTimeout ?? 30000);
80 | page.setDefaultTimeout(context.config.timeouts.defaultTimeout ?? 6000);
81 | (page as any)[tabSymbol] = this;
82 | }
83 |
84 | static forPage(page: playwright.Page): Tab | undefined {
85 | return (page as any)[tabSymbol];
86 | }
87 |
88 | modalStates(): ModalState[] {
89 | return this._modalStates;
90 | }
91 |
92 | setModalState(modalState: ModalState) {
93 | this._modalStates.push(modalState);
94 | this.emit(TabEvents.modalState, modalState);
95 | }
96 |
97 | clearModalState(modalState: ModalState) {
98 | this._modalStates = this._modalStates.filter(state => state !== modalState);
99 | }
100 |
101 | modalStatesMarkdown(): string[] {
102 | return renderModalStates(this.context, this.modalStates());
103 | }
104 |
105 | private _dialogShown(dialog: playwright.Dialog) {
106 | this.setModalState({
107 | type: 'dialog',
108 | description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
109 | dialog,
110 | });
111 | }
112 |
113 | private async _downloadStarted(download: playwright.Download) {
114 | const entry = {
115 | download,
116 | finished: false,
117 | outputFile: await this.context.outputFile(download.suggestedFilename())
118 | };
119 | this._downloads.push(entry);
120 | await download.saveAs(entry.outputFile);
121 | entry.finished = true;
122 | }
123 |
124 | private _clearCollectedArtifacts() {
125 | this._consoleMessages.length = 0;
126 | this._recentConsoleMessages.length = 0;
127 | this._requests.clear();
128 | }
129 |
130 | private _handleConsoleMessage(message: ConsoleMessage) {
131 | this._consoleMessages.push(message);
132 | this._recentConsoleMessages.push(message);
133 | }
134 |
135 | private _onClose() {
136 | this._clearCollectedArtifacts();
137 | this._onPageClose(this);
138 | }
139 |
140 | async updateTitle() {
141 | await this._raceAgainstModalStates(async () => {
142 | this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
143 | });
144 | }
145 |
146 | lastTitle(): string {
147 | return this._lastTitle;
148 | }
149 |
150 | isCurrentTab(): boolean {
151 | return this === this.context.currentTab();
152 | }
153 |
154 | async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
155 | await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
156 | }
157 |
158 | async navigate(url: string) {
159 | this._clearCollectedArtifacts();
160 |
161 | const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
162 | try {
163 | await this.page.goto(url, { waitUntil: 'domcontentloaded' });
164 | } catch (_e: unknown) {
165 | const e = _e as Error;
166 | const mightBeDownload =
167 | e.message.includes('net::ERR_ABORTED') // chromium
168 | || e.message.includes('Download is starting'); // firefox + webkit
169 | if (!mightBeDownload)
170 | throw e;
171 | // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
172 | const download = await Promise.race([
173 | downloadEvent,
174 | new Promise(resolve => setTimeout(resolve, 3000)),
175 | ]);
176 | if (!download)
177 | throw e;
178 | // Make sure other "download" listeners are notified first.
179 | await new Promise(resolve => setTimeout(resolve, 500));
180 | return;
181 | }
182 |
183 | // Cap load event to 5 seconds, the page is operational at this point.
184 | await this.waitForLoadState('load', { timeout: 5000 });
185 | }
186 |
187 | consoleMessages(): ConsoleMessage[] {
188 | return this._consoleMessages;
189 | }
190 |
191 | requests(): Map<playwright.Request, playwright.Response | null> {
192 | return this._requests;
193 | }
194 |
195 | async captureSnapshot(): Promise<TabSnapshot> {
196 | let tabSnapshot: TabSnapshot | undefined;
197 | const modalStates = await this._raceAgainstModalStates(async () => {
198 | const snapshot = await (this.page as PageEx)._snapshotForAI();
199 | tabSnapshot = {
200 | url: this.page.url(),
201 | title: await this.page.title(),
202 | ariaSnapshot: snapshot,
203 | modalStates: [],
204 | consoleMessages: [],
205 | downloads: this._downloads,
206 | };
207 | });
208 | if (tabSnapshot) {
209 | // Assign console message late so that we did not lose any to modal state.
210 | tabSnapshot.consoleMessages = this._recentConsoleMessages;
211 | this._recentConsoleMessages = [];
212 | }
213 | return tabSnapshot ?? {
214 | url: this.page.url(),
215 | title: '',
216 | ariaSnapshot: '',
217 | modalStates,
218 | consoleMessages: [],
219 | downloads: [],
220 | };
221 | }
222 |
223 | private _javaScriptBlocked(): boolean {
224 | return this._modalStates.some(state => state.type === 'dialog');
225 | }
226 |
227 | private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState[]> {
228 | if (this.modalStates().length)
229 | return this.modalStates();
230 |
231 | const promise = new ManualPromise<ModalState[]>();
232 | const listener = (modalState: ModalState) => promise.resolve([modalState]);
233 | this.once(TabEvents.modalState, listener);
234 |
235 | return await Promise.race([
236 | action().then(() => {
237 | this.off(TabEvents.modalState, listener);
238 | return [];
239 | }),
240 | promise,
241 | ]);
242 | }
243 |
244 | async waitForCompletion(callback: () => Promise<void>) {
245 | await this._raceAgainstModalStates(() => waitForCompletion(this, callback));
246 | }
247 |
248 | async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {
249 | return (await this.refLocators([params]))[0];
250 | }
251 |
252 | async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
253 | const snapshot = await (this.page as PageEx)._snapshotForAI();
254 | return params.map(param => {
255 | if (!snapshot.includes(`[ref=${param.ref}]`))
256 | throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
257 | return this.page.locator(`aria-ref=${param.ref}`).describe(param.element);
258 | });
259 | }
260 |
261 | async waitForTimeout(time: number) {
262 | if (this._javaScriptBlocked()) {
263 | await new Promise(f => setTimeout(f, time));
264 | return;
265 | }
266 |
267 | await callOnPageNoTrace(this.page, page => {
268 | return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
269 | });
270 | }
271 | }
272 |
273 | export type ConsoleMessage = {
274 | type: ReturnType<playwright.ConsoleMessage['type']> | undefined;
275 | text: string;
276 | toString(): string;
277 | };
278 |
279 | function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage {
280 | return {
281 | type: message.type(),
282 | text: message.text(),
283 | toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`,
284 | };
285 | }
286 |
287 | function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
288 | if (errorOrValue instanceof Error) {
289 | return {
290 | type: undefined,
291 | text: errorOrValue.message,
292 | toString: () => errorOrValue.stack || errorOrValue.message,
293 | };
294 | }
295 | return {
296 | type: undefined,
297 | text: String(errorOrValue),
298 | toString: () => String(errorOrValue),
299 | };
300 | }
301 |
302 | export function renderModalStates(context: Context, modalStates: ModalState[]): string[] {
303 | const result: string[] = ['### Modal state'];
304 | if (modalStates.length === 0)
305 | result.push('- There is no modal state present');
306 | for (const state of modalStates) {
307 | const tool = context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
308 | result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
309 | }
310 | return result;
311 | }
312 |
313 | const tabSymbol = Symbol('tabSymbol');
314 |
```
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
```typescript
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 | import fs from 'fs';
18 | import os from 'os';
19 | import path from 'path';
20 | import type {BrowserContextOptions, LaunchOptions} from 'playwright';
21 | import {devices} from 'playwright';
22 | import {sanitizeForFilePath} from './utils/fileUtils.js';
23 |
24 | import type {Config, ToolCapability} from '../config.js';
25 |
26 | export type CLIOptions = {
27 | allowedOrigins?: string[];
28 | blockedOrigins?: string[];
29 | blockServiceWorkers?: boolean;
30 | browser?: string;
31 | caps?: string[];
32 | cdpEndpoint?: string;
33 | config?: string;
34 | device?: string;
35 | executablePath?: string;
36 | headless?: boolean;
37 | host?: string;
38 | ignoreHttpsErrors?: boolean;
39 | isolated?: boolean;
40 | imageResponses?: 'allow' | 'omit';
41 | sandbox?: boolean;
42 | outputDir?: string;
43 | port?: number;
44 | proxyBypass?: string;
45 | proxyServer?: string;
46 | saveSession?: boolean;
47 | saveTrace?: boolean;
48 | storageState?: string;
49 | userAgent?: string;
50 | userDataDir?: string;
51 | viewportSize?: string;
52 | navigationTimeout?: number;
53 | defaultTimeout?: number;
54 | };
55 |
56 | const defaultConfig: FullConfig = {
57 | browser: {
58 | browserName: 'chromium',
59 | launchOptions: {
60 | channel: 'chrome',
61 | headless: os.platform() === 'linux' && !process.env.DISPLAY,
62 | chromiumSandbox: true,
63 | },
64 | contextOptions: {
65 | viewport: null,
66 | },
67 | },
68 | network: {
69 | allowedOrigins: undefined,
70 | blockedOrigins: undefined,
71 | },
72 | server: {},
73 | saveTrace: false,
74 | timeouts: {
75 | navigationTimeout: 60000,
76 | defaultTimeout: 5000,
77 | },
78 | };
79 |
80 | type BrowserUserConfig = NonNullable<Config['browser']>;
81 |
82 | export type FullConfig = Config & {
83 | browser: Omit<BrowserUserConfig, 'browserName'> & {
84 | browserName: 'chromium' | 'firefox' | 'webkit';
85 | launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
86 | contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
87 | },
88 | network: NonNullable<Config['network']>,
89 | saveTrace: boolean;
90 | server: NonNullable<Config['server']>,
91 | timeouts: NonNullable<Config['timeouts']>,
92 | };
93 |
94 | export async function resolveConfig(config: Config): Promise<FullConfig> {
95 | return mergeConfig(defaultConfig, config);
96 | }
97 |
98 | export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
99 | const configInFile = await loadConfig(cliOptions.config);
100 | const envOverrides = configFromEnv();
101 | const cliOverrides = configFromCLIOptions(cliOptions);
102 | let result = defaultConfig;
103 | result = mergeConfig(result, configInFile);
104 | result = mergeConfig(result, envOverrides);
105 | result = mergeConfig(result, cliOverrides);
106 | return result;
107 | }
108 |
109 | export function configFromCLIOptions(cliOptions: CLIOptions): Config {
110 | let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
111 | let channel: string | undefined;
112 | switch (cliOptions.browser) {
113 | case 'chrome':
114 | case 'chrome-beta':
115 | case 'chrome-canary':
116 | case 'chrome-dev':
117 | case 'chromium':
118 | case 'msedge':
119 | case 'msedge-beta':
120 | case 'msedge-canary':
121 | case 'msedge-dev':
122 | browserName = 'chromium';
123 | channel = cliOptions.browser;
124 | break;
125 | case 'firefox':
126 | browserName = 'firefox';
127 | break;
128 | case 'webkit':
129 | browserName = 'webkit';
130 | break;
131 | }
132 |
133 | // Launch options
134 | const launchOptions: LaunchOptions = {
135 | channel,
136 | executablePath: cliOptions.executablePath,
137 | headless: cliOptions.headless,
138 | };
139 |
140 | // --no-sandbox was passed, disable the sandbox
141 | if (cliOptions.sandbox === false)
142 | launchOptions.chromiumSandbox = false;
143 |
144 | if (cliOptions.proxyServer) {
145 | launchOptions.proxy = {
146 | server: cliOptions.proxyServer
147 | };
148 | if (cliOptions.proxyBypass)
149 | launchOptions.proxy.bypass = cliOptions.proxyBypass;
150 | }
151 |
152 | if (cliOptions.device && cliOptions.cdpEndpoint)
153 | throw new Error('Device emulation is not supported with cdpEndpoint.');
154 |
155 | // Context options
156 | const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
157 | if (cliOptions.storageState)
158 | contextOptions.storageState = cliOptions.storageState;
159 |
160 | if (cliOptions.userAgent)
161 | contextOptions.userAgent = cliOptions.userAgent;
162 |
163 | if (cliOptions.viewportSize) {
164 | try {
165 | const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
166 | if (isNaN(width) || isNaN(height))
167 | throw new Error('bad values');
168 | contextOptions.viewport = {width, height};
169 | } catch (e) {
170 | throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
171 | }
172 | }
173 |
174 | if (cliOptions.ignoreHttpsErrors)
175 | contextOptions.ignoreHTTPSErrors = true;
176 |
177 | if (cliOptions.blockServiceWorkers)
178 | contextOptions.serviceWorkers = 'block';
179 |
180 | return {
181 | browser: {
182 | browserName,
183 | isolated: cliOptions.isolated,
184 | userDataDir: cliOptions.userDataDir,
185 | launchOptions,
186 | contextOptions,
187 | cdpEndpoint: cliOptions.cdpEndpoint,
188 | },
189 | server: {
190 | port: cliOptions.port,
191 | host: cliOptions.host,
192 | },
193 | capabilities: cliOptions.caps as ToolCapability[],
194 | network: {
195 | allowedOrigins: cliOptions.allowedOrigins,
196 | blockedOrigins: cliOptions.blockedOrigins,
197 | },
198 | saveSession: cliOptions.saveSession,
199 | saveTrace: cliOptions.saveTrace,
200 | outputDir: cliOptions.outputDir,
201 | imageResponses: cliOptions.imageResponses,
202 | timeouts: {
203 | navigationTimeout: cliOptions.navigationTimeout,
204 | defaultTimeout: cliOptions.defaultTimeout,
205 | }
206 | };
207 | }
208 |
209 | function configFromEnv(): Config {
210 | const options: CLIOptions = {};
211 | options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
212 | options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
213 | options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
214 | options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
215 | options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
216 | options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
217 | options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
218 | options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
219 | options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
220 | options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
221 | options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
222 | options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
223 | options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
224 | if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
225 | options.imageResponses = 'omit';
226 | options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
227 | options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
228 | options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
229 | options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
230 | options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
231 | options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
232 | options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
233 | options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
234 | options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
235 | options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
236 | options.navigationTimeout = envToNumber(process.env.PLAYWRIGHT_MCP_NAVIGATION_TIMEOUT);
237 | options.defaultTimeout = envToNumber(process.env.PLAYWRIGHT_MCP_DEFAULT_TIMEOUT);
238 | return configFromCLIOptions(options);
239 | }
240 |
241 | async function loadConfig(configFile: string | undefined): Promise<Config> {
242 | if (!configFile)
243 | return {};
244 |
245 | try {
246 | return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
247 | } catch (error) {
248 | throw new Error(`Failed to load config file: ${configFile}, ${error}`);
249 | }
250 | }
251 |
252 | export async function outputFile(config: FullConfig, rootPath: string | undefined, name: string): Promise<string> {
253 | const outputDir = config.outputDir
254 | ?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
255 | ?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
256 |
257 | await fs.promises.mkdir(outputDir, {recursive: true});
258 | const fileName = sanitizeForFilePath(name);
259 | return path.join(outputDir, fileName);
260 | }
261 |
262 | function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
263 | return Object.fromEntries(
264 | Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
265 | ) as Partial<T>;
266 | }
267 |
268 | function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
269 | const browser: FullConfig['browser'] = {
270 | ...pickDefined(base.browser),
271 | ...pickDefined(overrides.browser),
272 | browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
273 | isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
274 | launchOptions: {
275 | ...pickDefined(base.browser?.launchOptions),
276 | ...pickDefined(overrides.browser?.launchOptions),
277 | ...{assistantMode: true},
278 | },
279 | contextOptions: {
280 | ...pickDefined(base.browser?.contextOptions),
281 | ...pickDefined(overrides.browser?.contextOptions),
282 | },
283 | };
284 |
285 | if (browser.browserName !== 'chromium' && browser.launchOptions)
286 | delete browser.launchOptions.channel;
287 |
288 | return {
289 | ...pickDefined(base),
290 | ...pickDefined(overrides),
291 | browser,
292 | network: {
293 | ...pickDefined(base.network),
294 | ...pickDefined(overrides.network),
295 | },
296 | server: {
297 | ...pickDefined(base.server),
298 | ...pickDefined(overrides.server),
299 | },
300 | timeouts: {
301 | navigationTimeout: overrides.timeouts?.navigationTimeout ?? base.timeouts.navigationTimeout,
302 | defaultTimeout: overrides.timeouts?.defaultTimeout ?? base.timeouts.defaultTimeout,
303 | },
304 | } as FullConfig;
305 | }
306 |
307 | export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
308 | if (!value)
309 | return undefined;
310 | return value.split(';').map(v => v.trim());
311 | }
312 |
313 | export function commaSeparatedList(value: string | undefined): string[] | undefined {
314 | if (!value)
315 | return undefined;
316 | return value.split(',').map(v => v.trim());
317 | }
318 |
319 | function envToNumber(value: string | undefined): number | undefined {
320 | if (!value)
321 | return undefined;
322 | return +value;
323 | }
324 |
325 | function envToBoolean(value: string | undefined): boolean | undefined {
326 | if (value === 'true' || value === '1')
327 | return true;
328 | if (value === 'false' || value === '0')
329 | return false;
330 | return undefined;
331 | }
332 |
333 | function envToString(value: string | undefined): string | undefined {
334 | return value ? value.trim() : undefined;
335 | }
336 |
```
--------------------------------------------------------------------------------
/src/extension/cdpRelay.ts:
--------------------------------------------------------------------------------
```typescript
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 | /**
18 | * WebSocket server that bridges Playwright MCP and Chrome Extension
19 | *
20 | * Endpoints:
21 | * - /cdp/guid - Full CDP interface for Playwright MCP
22 | * - /extension/guid - Extension connection for chrome.debugger forwarding
23 | */
24 |
25 | import { spawn } from 'child_process';
26 | import http from 'http';
27 | import debug from 'debug';
28 | import { WebSocket, WebSocketServer } from 'ws';
29 | import { httpAddressToString } from '../mcp/http.js';
30 | import { logUnhandledError } from '../utils/log.js';
31 | import { ManualPromise } from '../mcp/manualPromise.js';
32 | import * as protocol from './protocol.js';
33 |
34 | import type websocket from 'ws';
35 | import type { ClientInfo } from '../browserContextFactory.js';
36 | import type { ExtensionCommand, ExtensionEvents } from './protocol.js';
37 |
38 | // @ts-ignore
39 | const { registry } = await import('playwright-core/lib/server/registry/index');
40 |
41 | const debugLogger = debug('pw:mcp:relay');
42 |
43 | type CDPCommand = {
44 | id: number;
45 | sessionId?: string;
46 | method: string;
47 | params?: any;
48 | };
49 |
50 | type CDPResponse = {
51 | id?: number;
52 | sessionId?: string;
53 | method?: string;
54 | params?: any;
55 | result?: any;
56 | error?: { code?: number; message: string };
57 | };
58 |
59 | export class CDPRelayServer {
60 | private _wsHost: string;
61 | private _browserChannel: string;
62 | private _userDataDir?: string;
63 | private _executablePath?: string;
64 | private _cdpPath: string;
65 | private _extensionPath: string;
66 | private _wss: WebSocketServer;
67 | private _playwrightConnection: WebSocket | null = null;
68 | private _extensionConnection: ExtensionConnection | null = null;
69 | private _connectedTabInfo: {
70 | targetInfo: any;
71 | // Page sessionId that should be used by this connection.
72 | sessionId: string;
73 | } | undefined;
74 | private _nextSessionId: number = 1;
75 | private _extensionConnectionPromise!: ManualPromise<void>;
76 |
77 | constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: string) {
78 | this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
79 | this._browserChannel = browserChannel;
80 | this._userDataDir = userDataDir;
81 | this._executablePath = executablePath;
82 |
83 | const uuid = crypto.randomUUID();
84 | this._cdpPath = `/cdp/${uuid}`;
85 | this._extensionPath = `/extension/${uuid}`;
86 |
87 | this._resetExtensionConnection();
88 | this._wss = new WebSocketServer({ server });
89 | this._wss.on('connection', this._onConnection.bind(this));
90 | }
91 |
92 | cdpEndpoint() {
93 | return `${this._wsHost}${this._cdpPath}`;
94 | }
95 |
96 | extensionEndpoint() {
97 | return `${this._wsHost}${this._extensionPath}`;
98 | }
99 |
100 | async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) {
101 | debugLogger('Ensuring extension connection for MCP context');
102 | if (this._extensionConnection)
103 | return;
104 | this._connectBrowser(clientInfo, toolName);
105 | debugLogger('Waiting for incoming extension connection');
106 | await Promise.race([
107 | this._extensionConnectionPromise,
108 | new Promise((_, reject) => setTimeout(() => {
109 | reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
110 | }, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
111 | new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
112 | ]);
113 | debugLogger('Extension connection established');
114 | }
115 |
116 | private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) {
117 | const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
118 | // Need to specify "key" in the manifest.json to make the id stable when loading from file.
119 | const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
120 | url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
121 | const client = {
122 | name: clientInfo.name,
123 | version: clientInfo.version,
124 | };
125 | url.searchParams.set('client', JSON.stringify(client));
126 | url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
127 | if (toolName)
128 | url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
129 | const href = url.toString();
130 |
131 | let executablePath = this._executablePath;
132 | if (!executablePath) {
133 | const executableInfo = registry.findExecutable(this._browserChannel);
134 | if (!executableInfo)
135 | throw new Error(`Unsupported channel: "${this._browserChannel}"`);
136 | executablePath = executableInfo.executablePath();
137 | if (!executablePath)
138 | throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
139 | }
140 |
141 | const args: string[] = [];
142 | if (this._userDataDir)
143 | args.push(`--user-data-dir=${this._userDataDir}`);
144 | args.push(href);
145 |
146 | spawn(executablePath, args, {
147 | windowsHide: true,
148 | detached: true,
149 | shell: false,
150 | stdio: 'ignore',
151 | });
152 | }
153 |
154 | stop(): void {
155 | this.closeConnections('Server stopped');
156 | this._wss.close();
157 | }
158 |
159 | closeConnections(reason: string) {
160 | this._closePlaywrightConnection(reason);
161 | this._closeExtensionConnection(reason);
162 | }
163 |
164 | private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
165 | const url = new URL(`http://localhost${request.url}`);
166 | debugLogger(`New connection to ${url.pathname}`);
167 | if (url.pathname === this._cdpPath) {
168 | this._handlePlaywrightConnection(ws);
169 | } else if (url.pathname === this._extensionPath) {
170 | this._handleExtensionConnection(ws);
171 | } else {
172 | debugLogger(`Invalid path: ${url.pathname}`);
173 | ws.close(4004, 'Invalid path');
174 | }
175 | }
176 |
177 | private _handlePlaywrightConnection(ws: WebSocket): void {
178 | if (this._playwrightConnection) {
179 | debugLogger('Rejecting second Playwright connection');
180 | ws.close(1000, 'Another CDP client already connected');
181 | return;
182 | }
183 | this._playwrightConnection = ws;
184 | ws.on('message', async data => {
185 | try {
186 | const message = JSON.parse(data.toString());
187 | await this._handlePlaywrightMessage(message);
188 | } catch (error: any) {
189 | debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
190 | }
191 | });
192 | ws.on('close', () => {
193 | if (this._playwrightConnection !== ws)
194 | return;
195 | this._playwrightConnection = null;
196 | this._closeExtensionConnection('Playwright client disconnected');
197 | debugLogger('Playwright WebSocket closed');
198 | });
199 | ws.on('error', error => {
200 | debugLogger('Playwright WebSocket error:', error);
201 | });
202 | debugLogger('Playwright MCP connected');
203 | }
204 |
205 | private _closeExtensionConnection(reason: string) {
206 | this._extensionConnection?.close(reason);
207 | this._extensionConnectionPromise.reject(new Error(reason));
208 | this._resetExtensionConnection();
209 | }
210 |
211 | private _resetExtensionConnection() {
212 | this._connectedTabInfo = undefined;
213 | this._extensionConnection = null;
214 | this._extensionConnectionPromise = new ManualPromise();
215 | void this._extensionConnectionPromise.catch(logUnhandledError);
216 | }
217 |
218 | private _closePlaywrightConnection(reason: string) {
219 | if (this._playwrightConnection?.readyState === WebSocket.OPEN)
220 | this._playwrightConnection.close(1000, reason);
221 | this._playwrightConnection = null;
222 | }
223 |
224 | private _handleExtensionConnection(ws: WebSocket): void {
225 | if (this._extensionConnection) {
226 | ws.close(1000, 'Another extension connection already established');
227 | return;
228 | }
229 | this._extensionConnection = new ExtensionConnection(ws);
230 | this._extensionConnection.onclose = (c, reason) => {
231 | debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
232 | if (this._extensionConnection !== c)
233 | return;
234 | this._resetExtensionConnection();
235 | this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
236 | };
237 | this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
238 | this._extensionConnectionPromise.resolve();
239 | }
240 |
241 | private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
242 | switch (method) {
243 | case 'forwardCDPEvent':
244 | const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
245 | this._sendToPlaywright({
246 | sessionId,
247 | method: params.method,
248 | params: params.params
249 | });
250 | break;
251 | }
252 | }
253 |
254 | private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
255 | debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
256 | const { id, sessionId, method, params } = message;
257 | try {
258 | const result = await this._handleCDPCommand(method, params, sessionId);
259 | this._sendToPlaywright({ id, sessionId, result });
260 | } catch (e) {
261 | debugLogger('Error in the extension:', e);
262 | this._sendToPlaywright({
263 | id,
264 | sessionId,
265 | error: { message: (e as Error).message }
266 | });
267 | }
268 | }
269 |
270 | private async _handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<any> {
271 | switch (method) {
272 | case 'Browser.getVersion': {
273 | return {
274 | protocolVersion: '1.3',
275 | product: 'Chrome/Extension-Bridge',
276 | userAgent: 'CDP-Bridge-Server/1.0.0',
277 | };
278 | }
279 | case 'Browser.setDownloadBehavior': {
280 | return { };
281 | }
282 | case 'Target.setAutoAttach': {
283 | // Forward child session handling.
284 | if (sessionId)
285 | break;
286 | // Simulate auto-attach behavior with real target info
287 | const { targetInfo } = await this._extensionConnection!.send('attachToTab', { });
288 | this._connectedTabInfo = {
289 | targetInfo,
290 | sessionId: `pw-tab-${this._nextSessionId++}`,
291 | };
292 | debugLogger('Simulating auto-attach');
293 | this._sendToPlaywright({
294 | method: 'Target.attachedToTarget',
295 | params: {
296 | sessionId: this._connectedTabInfo.sessionId,
297 | targetInfo: {
298 | ...this._connectedTabInfo.targetInfo,
299 | attached: true,
300 | },
301 | waitingForDebugger: false
302 | }
303 | });
304 | return { };
305 | }
306 | case 'Target.getTargetInfo': {
307 | return this._connectedTabInfo?.targetInfo;
308 | }
309 | }
310 | return await this._forwardToExtension(method, params, sessionId);
311 | }
312 |
313 | private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
314 | if (!this._extensionConnection)
315 | throw new Error('Extension not connected');
316 | // Top level sessionId is only passed between the relay and the client.
317 | if (this._connectedTabInfo?.sessionId === sessionId)
318 | sessionId = undefined;
319 | return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
320 | }
321 |
322 | private _sendToPlaywright(message: CDPResponse): void {
323 | debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
324 | this._playwrightConnection?.send(JSON.stringify(message));
325 | }
326 | }
327 |
328 | type ExtensionResponse = {
329 | id?: number;
330 | method?: string;
331 | params?: any;
332 | result?: any;
333 | error?: string;
334 | };
335 |
336 | class ExtensionConnection {
337 | private readonly _ws: WebSocket;
338 | private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
339 | private _lastId = 0;
340 |
341 | onmessage?: <M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) => void;
342 | onclose?: (self: ExtensionConnection, reason: string) => void;
343 |
344 | constructor(ws: WebSocket) {
345 | this._ws = ws;
346 | this._ws.on('message', this._onMessage.bind(this));
347 | this._ws.on('close', this._onClose.bind(this));
348 | this._ws.on('error', this._onError.bind(this));
349 | }
350 |
351 | async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<any> {
352 | if (this._ws.readyState !== WebSocket.OPEN)
353 | throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
354 | const id = ++this._lastId;
355 | this._ws.send(JSON.stringify({ id, method, params }));
356 | const error = new Error(`Protocol error: ${method}`);
357 | return new Promise((resolve, reject) => {
358 | this._callbacks.set(id, { resolve, reject, error });
359 | });
360 | }
361 |
362 | close(message: string) {
363 | debugLogger('closing extension connection:', message);
364 | if (this._ws.readyState === WebSocket.OPEN)
365 | this._ws.close(1000, message);
366 | }
367 |
368 | private _onMessage(event: websocket.RawData) {
369 | const eventData = event.toString();
370 | let parsedJson;
371 | try {
372 | parsedJson = JSON.parse(eventData);
373 | } catch (e: any) {
374 | debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
375 | this._ws.close();
376 | return;
377 | }
378 | try {
379 | this._handleParsedMessage(parsedJson);
380 | } catch (e: any) {
381 | debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
382 | this._ws.close();
383 | }
384 | }
385 |
386 | private _handleParsedMessage(object: ExtensionResponse) {
387 | if (object.id && this._callbacks.has(object.id)) {
388 | const callback = this._callbacks.get(object.id)!;
389 | this._callbacks.delete(object.id);
390 | if (object.error) {
391 | const error = callback.error;
392 | error.message = object.error;
393 | callback.reject(error);
394 | } else {
395 | callback.resolve(object.result);
396 | }
397 | } else if (object.id) {
398 | debugLogger('← Extension: unexpected response', object);
399 | } else {
400 | this.onmessage?.(object.method! as keyof ExtensionEvents, object.params);
401 | }
402 | }
403 |
404 | private _onClose(event: websocket.CloseEvent) {
405 | debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
406 | this._dispose();
407 | this.onclose?.(this, event.reason);
408 | }
409 |
410 | private _onError(event: websocket.ErrorEvent) {
411 | debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
412 | this._dispose();
413 | }
414 |
415 | private _dispose() {
416 | for (const callback of this._callbacks.values())
417 | callback.reject(new Error('WebSocket closed'));
418 | this._callbacks.clear();
419 | }
420 | }
421 |
```