diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 3cfbacac0..6619d60b0 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -7,6 +7,17 @@ import type {YargsOptions} from '../third_party/index.js'; import {yargs, hideBin} from '../third_party/index.js'; +/** + * On macOS, Node.js `dns.lookup('localhost')` non-deterministically resolves + * to `::1` (IPv6) or `127.0.0.1` (IPv4) depending on network state. Chrome's + * debug port typically binds only one address family, causing intermittent + * connection failures. This function replaces `localhost` with `127.0.0.1` + * to match Chrome's default binding while preserving the original URL format. + */ +function resolveLocalhostToIPv4(url: string): string { + return url.replace(/\/\/localhost([:/])/i, '//127.0.0.1$1'); +} + export const cliOptions = { autoConnect: { type: 'boolean', @@ -36,7 +47,7 @@ export const cliOptions = { } catch { throw new Error(`Provided browserUrl ${url} is not valid URL.`); } - return url; + return resolveLocalhostToIPv4(url); }, }, wsEndpoint: { @@ -56,7 +67,7 @@ export const cliOptions = { `Provided wsEndpoint ${url} must use ws:// or wss:// protocol.`, ); } - return url; + return resolveLocalhostToIPv4(url); } catch (error) { if ((error as Error).message.includes('ws://')) { throw error; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index b18a4532f..8fee3dc15 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -43,17 +43,58 @@ describe('cli args parsing', () => { '--browserUrl', 'http://localhost:3000', ]); + // localhost is resolved to 127.0.0.1 to avoid IPv6 resolution issues assert.deepStrictEqual(args, { ...defaultArgs, _: [], headless: false, $0: 'npx chrome-devtools-mcp@latest', - 'browser-url': 'http://localhost:3000', - browserUrl: 'http://localhost:3000', - u: 'http://localhost:3000', + 'browser-url': 'http://127.0.0.1:3000', + browserUrl: 'http://127.0.0.1:3000', + u: 'http://127.0.0.1:3000', }); }); + it('resolves localhost to 127.0.0.1 in browser url', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browserUrl', + 'http://localhost:9222', + ]); + assert.strictEqual(args.browserUrl, 'http://127.0.0.1:9222'); + }); + + it('resolves LOCALHOST to 127.0.0.1 in browser url', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browserUrl', + 'http://LOCALHOST:9222', + ]); + assert.strictEqual(args.browserUrl, 'http://127.0.0.1:9222'); + }); + + it('preserves explicit 127.0.0.1 in browser url', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browserUrl', + 'http://127.0.0.1:9222', + ]); + assert.strictEqual(args.browserUrl, 'http://127.0.0.1:9222'); + }); + + it('preserves explicit [::1] in browser url', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browserUrl', + 'http://[::1]:9222', + ]); + assert.strictEqual(args.browserUrl, 'http://[::1]:9222'); + }); + it('parses with user data dir', async () => { const args = parseArguments('1.0.0', [ 'node', @@ -207,6 +248,19 @@ describe('cli args parsing', () => { }); }); + it('resolves localhost to 127.0.0.1 in wsEndpoint', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--wsEndpoint', + 'ws://localhost:9222/devtools/browser/abc123', + ]); + assert.strictEqual( + args.wsEndpoint, + 'ws://127.0.0.1:9222/devtools/browser/abc123', + ); + }); + it('parses wsHeaders with valid JSON', async () => { const args = parseArguments('1.0.0', [ 'node',