diff --git a/README.md b/README.md index 1aaab3e66..68b68de63 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,14 @@ The Chrome DevTools MCP server supports the following configuration option: Connect to a running Chrome instance using port forwarding. For more details see: https://developer.chrome.com/docs/devtools/remote-debugging/local-server. - **Type:** string +- **`--browserWsEndpoint`, `-w`** + WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/). Alternative to --browserUrl. + - **Type:** string + +- **`--wsHeaders`** + Custom headers for WebSocket connection in JSON format (e.g., '{"Authorization":"Bearer token"}'). Only works with --browserWsEndpoint. + - **Type:** string + - **`--headless`** Whether to run in headless (no UI) mode. - **Type:** boolean @@ -343,6 +351,27 @@ Pass them via the `args` property in the JSON configuration. For example: } ``` +### Connecting via WebSocket with custom headers + +You can connect directly to a Chrome WebSocket endpoint and include custom headers (e.g., for authentication): + +```json +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "chrome-devtools-mcp@latest", + "--browserWsEndpoint=ws://127.0.0.1:9222/devtools/browser/", + "--wsHeaders={\"Authorization\":\"Bearer YOUR_TOKEN\"}" + ] + } + } +} +``` + +To get the WebSocket endpoint from a running Chrome instance, visit `http://127.0.0.1:9222/json/version` and look for the `webSocketDebuggerUrl` field. + You can also run `npx chrome-devtools-mcp@latest --help` to see all available configuration options. ## Concepts diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..149c517b5 --- /dev/null +++ b/plan.md @@ -0,0 +1,131 @@ +# Plan: Add WebSocket Endpoint and Headers Support + +## Overview + +Add support for connecting to Chrome via WebSocket endpoint with custom headers, in addition to the existing `--browserUrl` argument. + +## Puppeteer Support Verification ✅ + +Based on Puppeteer documentation, the `puppeteer.connect()` method supports: + +- **`browserWSEndpoint`** (string): WebSocket URL to connect directly to the browser (e.g., `ws://127.0.0.1:9222/devtools/browser/`) +- **`headers`** (Record): Custom headers for the WebSocket connection (Node.js only) + +Source: `ConnectOptions` interface in Puppeteer Core + +## Current Implementation + +- `--browserUrl` / `-u`: Connects using `puppeteer.connect({ browserURL: ... })` +- Uses HTTP endpoint which Puppeteer converts to WebSocket internally +- No support for custom headers + +## Proposed Changes + +### 1. CLI Arguments (src/cli.ts) + +Add two new arguments: + +```typescript +browserWsEndpoint: { + type: 'string', + description: 'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/). Alternative to --browserUrl.', + alias: 'w', + conflicts: 'browserUrl', +} + +wsHeaders: { + type: 'string', + description: 'Custom headers for WebSocket connection in JSON format (e.g., \'{"Authorization":"Bearer token"}\'). Only works with --browserWsEndpoint.', + coerce: (val: string | undefined) => { + if (!val) return undefined; + try { + const parsed = JSON.parse(val); + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Headers must be a JSON object'); + } + return parsed; + } catch (error) { + throw new Error(`Invalid JSON for wsHeaders: ${error.message}`); + } + }, + implies: 'browserWsEndpoint', +} +``` + +### 2. Browser Connection (src/browser.ts) + +Update `ensureBrowserConnected` function: + +```typescript +export async function ensureBrowserConnected(options: { + browserURL?: string; + browserWSEndpoint?: string; + headers?: Record; + devtools: boolean; +}) { + if (browser?.connected) { + return browser; + } + + const connectOptions: ConnectOptions = { + targetFilter: makeTargetFilter(options.devtools), + defaultViewport: null, + handleDevToolsAsPage: options.devtools, + }; + + if (options.browserWSEndpoint) { + connectOptions.browserWSEndpoint = options.browserWSEndpoint; + if (options.headers) { + connectOptions.headers = options.headers; + } + } else if (options.browserURL) { + connectOptions.browserURL = options.browserURL; + } + + browser = await puppeteer.connect(connectOptions); + return browser; +} +``` + +### 3. Main Entry Point (src/main.ts) + +Update `getContext()` to pass new options: + +```typescript +const browser = args.browserUrl || args.browserWsEndpoint + ? await ensureBrowserConnected({ + browserURL: args.browserUrl, + browserWSEndpoint: args.browserWsEndpoint, + headers: args.wsHeaders, + devtools, + }) + : await ensureBrowserLaunched({...}); +``` + +### 4. Documentation (README.md) + +Add new options to the configuration section: + +- `--browserWsEndpoint`, `-w`: WebSocket endpoint +- `--wsHeaders`: Custom headers in JSON format + +Add usage examples showing: + +1. Basic WebSocket connection +2. WebSocket with authentication headers +3. Comparison with browserUrl approach + +## Benefits + +1. **Direct WebSocket connection**: Bypass HTTP endpoint resolution +2. **Authentication support**: Pass custom headers for secured browser instances +3. **Flexibility**: Choose between HTTP URL or WebSocket endpoint based on use case +4. **API keys/tokens**: Support authenticated remote debugging scenarios + +## Testing Considerations + +- Verify WebSocket URL validation +- Test header JSON parsing (valid/invalid cases) +- Ensure headers only work with WebSocket endpoint +- Test mutual exclusivity of browserUrl and browserWsEndpoint +- Verify wsHeaders requires browserWsEndpoint diff --git a/src/browser.ts b/src/browser.ts index a54d4e4b2..4b48468a2 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -42,18 +42,33 @@ function makeTargetFilter(devtools: boolean) { } export async function ensureBrowserConnected(options: { - browserURL: string; + browserURL?: string; + browserWSEndpoint?: string; + headers?: Record; devtools: boolean; }) { if (browser?.connected) { return browser; } - browser = await puppeteer.connect({ + + const connectOptions: Parameters[0] = { targetFilter: makeTargetFilter(options.devtools), - browserURL: options.browserURL, defaultViewport: null, handleDevToolsAsPage: options.devtools, - }); + }; + + if (options.browserWSEndpoint) { + connectOptions.browserWSEndpoint = options.browserWSEndpoint; + if (options.headers) { + connectOptions.headers = options.headers; + } + } else if (options.browserURL) { + connectOptions.browserURL = options.browserURL; + } else { + throw new Error('Either browserURL or browserWSEndpoint must be provided'); + } + + browser = await puppeteer.connect(connectOptions); return browser; } diff --git a/src/cli.ts b/src/cli.ts index 513fff338..f7a0c80db 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ export const cliOptions = { description: 'Connect to a running Chrome instance using port forwarding. For more details see: https://developer.chrome.com/docs/devtools/remote-debugging/local-server.', alias: 'u', + conflicts: 'browserWsEndpoint', coerce: (url: string | undefined) => { if (!url) { return; @@ -26,6 +27,54 @@ export const cliOptions = { return url; }, }, + browserWsEndpoint: { + type: 'string', + description: + 'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/). Alternative to --browserUrl.', + alias: 'w', + conflicts: 'browserUrl', + coerce: (url: string | undefined) => { + if (!url) { + return; + } + try { + const parsed = new URL(url); + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { + throw new Error( + `Provided browserWsEndpoint ${url} must use ws:// or wss:// protocol.`, + ); + } + return url; + } catch (error) { + if ((error as Error).message.includes('ws://')) { + throw error; + } + throw new Error(`Provided browserWsEndpoint ${url} is not valid URL.`); + } + }, + }, + wsHeaders: { + type: 'string', + description: + 'Custom headers for WebSocket connection in JSON format (e.g., \'{"Authorization":"Bearer token"}\'). Only works with --browserWsEndpoint.', + implies: 'browserWsEndpoint', + coerce: (val: string | undefined) => { + if (!val) { + return; + } + try { + const parsed = JSON.parse(val); + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Headers must be a JSON object'); + } + return parsed as Record; + } catch (error) { + throw new Error( + `Invalid JSON for wsHeaders: ${(error as Error).message}`, + ); + } + }, + }, headless: { type: 'boolean', description: 'Whether to run in headless (no UI) mode.', @@ -34,7 +83,7 @@ export const cliOptions = { executablePath: { type: 'string', description: 'Path to custom Chrome executable.', - conflicts: 'browserUrl', + conflicts: ['browserUrl', 'browserWsEndpoint'], alias: 'e', }, isolated: { @@ -48,7 +97,7 @@ export const cliOptions = { description: 'Specify a different Chrome channel that should be used. The default is the stable channel version.', choices: ['stable', 'canary', 'beta', 'dev'] as const, - conflicts: ['browserUrl', 'executablePath'], + conflicts: ['browserUrl', 'browserWsEndpoint', 'executablePath'], }, logFile: { type: 'string', @@ -100,7 +149,12 @@ export function parseArguments(version: string, argv = process.argv) { .check(args => { // We can't set default in the options else // Yargs will complain - if (!args.channel && !args.browserUrl && !args.executablePath) { + if ( + !args.channel && + !args.browserUrl && + !args.browserWsEndpoint && + !args.executablePath + ) { args.channel = 'stable'; } return true; @@ -108,7 +162,15 @@ export function parseArguments(version: string, argv = process.argv) { .example([ [ '$0 --browserUrl http://127.0.0.1:9222', - 'Connect to an existing browser instance', + 'Connect to an existing browser instance via HTTP', + ], + [ + '$0 --browserWsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123', + 'Connect to an existing browser instance via WebSocket', + ], + [ + `$0 --browserWsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123 --wsHeaders '{"Authorization":"Bearer token"}'`, + 'Connect via WebSocket with custom headers', ], ['$0 --channel beta', 'Use Chrome Beta installed on this system'], ['$0 --channel canary', 'Use Chrome Canary installed on this system'], diff --git a/src/main.ts b/src/main.ts index d86ad6dc9..09bcfa736 100644 --- a/src/main.ts +++ b/src/main.ts @@ -58,22 +58,25 @@ async function getContext(): Promise { extraArgs.push(`--proxy-server=${args.proxyServer}`); } const devtools = args.experimentalDevtools ?? false; - const browser = args.browserUrl - ? await ensureBrowserConnected({ - browserURL: args.browserUrl, - devtools, - }) - : await ensureBrowserLaunched({ - headless: args.headless, - executablePath: args.executablePath, - channel: args.channel as Channel, - isolated: args.isolated, - logFile, - viewport: args.viewport, - args: extraArgs, - acceptInsecureCerts: args.acceptInsecureCerts, - devtools, - }); + const browser = + args.browserUrl || args.browserWsEndpoint + ? await ensureBrowserConnected({ + browserURL: args.browserUrl, + browserWSEndpoint: args.browserWsEndpoint, + headers: args.wsHeaders, + devtools, + }) + : await ensureBrowserLaunched({ + headless: args.headless, + executablePath: args.executablePath, + channel: args.channel as Channel, + isolated: args.isolated, + logFile, + viewport: args.viewport, + args: extraArgs, + acceptInsecureCerts: args.acceptInsecureCerts, + devtools, + }); if (context?.browser !== browser) { context = await McpContext.from(browser, logger);