From 7a956ab13e3fe79b0e1601cb74438aff8fd33760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=C3=A0o=20Ho=C3=A0ng=20S=C6=A1n?= Date: Fri, 17 Oct 2025 06:39:49 +0700 Subject: [PATCH] feat: add WebSocket endpoint and custom headers support Add support for connecting to Chrome via WebSocket endpoint with custom headers, enabling authenticated remote debugging scenarios and providing an alternative to the HTTP-based connection method. This commit introduces two new CLI arguments: - **`--wsEndpoint` / `-w`**: Connect directly to Chrome using a WebSocket URL - Example: `ws://127.0.0.1:9222/devtools/browser/` - Alternative to `--browserUrl` (mutually exclusive) - **`--wsHeaders`**: Pass custom headers for WebSocket connections (JSON format) - Example: `'{"Authorization":"Bearer token"}'` - Only works with `--wsEndpoint` --- README.md | 29 ++++++++++++++++++++ src/browser.ts | 23 +++++++++++++--- src/cli.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++--- src/main.ts | 35 +++++++++++++----------- tests/cli.test.ts | 51 ++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0b78b85b..bf87b8df 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 +- **`--wsEndpoint`, `-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 --wsEndpoint. + - **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", + "--wsEndpoint=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/src/browser.ts b/src/browser.ts index a54d4e4b..d76d5a9f 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; + wsEndpoint?: string; + wsHeaders?: 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.wsEndpoint) { + connectOptions.browserWSEndpoint = options.wsEndpoint; + if (options.wsHeaders) { + connectOptions.headers = options.wsHeaders; + } + } else if (options.browserURL) { + connectOptions.browserURL = options.browserURL; + } else { + throw new Error('Either browserURL or wsEndpoint must be provided'); + } + + browser = await puppeteer.connect(connectOptions); return browser; } diff --git a/src/cli.ts b/src/cli.ts index 513fff33..89c8097d 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: 'wsEndpoint', coerce: (url: string | undefined) => { if (!url) { return; @@ -26,6 +27,54 @@ export const cliOptions = { return url; }, }, + wsEndpoint: { + 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 wsEndpoint ${url} must use ws:// or wss:// protocol.`, + ); + } + return url; + } catch (error) { + if ((error as Error).message.includes('ws://')) { + throw error; + } + throw new Error(`Provided wsEndpoint ${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 --wsEndpoint.', + implies: 'wsEndpoint', + 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', 'wsEndpoint'], 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', 'wsEndpoint', '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.wsEndpoint && + !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 --wsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123', + 'Connect to an existing browser instance via WebSocket', + ], + [ + `$0 --wsEndpoint 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 d86ad6dc..a9e63f95 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.wsEndpoint + ? await ensureBrowserConnected({ + browserURL: args.browserUrl, + wsEndpoint: args.wsEndpoint, + wsHeaders: 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); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index ab46582f..2c66ac13 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -112,4 +112,55 @@ describe('cli args parsing', () => { chromeArg: ['--no-sandbox', '--disable-setuid-sandbox'], }); }); + + it('parses wsEndpoint with ws:// protocol', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--wsEndpoint', + 'ws://127.0.0.1:9222/devtools/browser/abc123', + ]); + assert.deepStrictEqual(args, { + _: [], + headless: false, + isolated: false, + $0: 'npx chrome-devtools-mcp@latest', + 'ws-endpoint': 'ws://127.0.0.1:9222/devtools/browser/abc123', + wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/abc123', + w: 'ws://127.0.0.1:9222/devtools/browser/abc123', + }); + }); + + it('parses wsEndpoint with wss:// protocol', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--wsEndpoint', + 'wss://example.com:9222/devtools/browser/abc123', + ]); + assert.deepStrictEqual(args, { + _: [], + headless: false, + isolated: false, + $0: 'npx chrome-devtools-mcp@latest', + 'ws-endpoint': 'wss://example.com:9222/devtools/browser/abc123', + wsEndpoint: 'wss://example.com:9222/devtools/browser/abc123', + w: 'wss://example.com:9222/devtools/browser/abc123', + }); + }); + + it('parses wsHeaders with valid JSON', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--wsEndpoint', + 'ws://127.0.0.1:9222/devtools/browser/abc123', + '--wsHeaders', + '{"Authorization":"Bearer token","X-Custom":"value"}', + ]); + assert.deepStrictEqual(args.wsHeaders, { + Authorization: 'Bearer token', + 'X-Custom': 'value', + }); + }); });