#
tokens: 22383/50000 7/67 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 2/2FirstPrevNextLast