From eee954e4a143dd5c0c8cfc2259acae505a75adde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 12:51:16 +0100 Subject: [PATCH] fix: patch chrome-devtools auto-connect hang --- CHANGELOG.md | 6 +- README.md | 3 +- config/mcporter.json | 2 +- docs/logging.md | 2 +- src/chrome-devtools-auto-connect-patch.ts | 72 ++++++++++ src/chrome-devtools-compat.ts | 162 ++++++++++++++++++++++ src/runtime/transport.ts | 13 +- tests/chrome-devtools-compat.test.ts | 102 ++++++++++++++ tests/fixtures/mcporter.json | 2 +- 9 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 src/chrome-devtools-auto-connect-patch.ts create mode 100644 src/chrome-devtools-compat.ts create mode 100644 tests/chrome-devtools-compat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2558a6..d1be4ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,16 @@ # mcporter Changelog -## [Unreleased] +## [0.11.0] - Unreleased ### Config - Resolve `${VAR}` and `${VAR:-fallback}` placeholders across string-valued server config fields such as `baseUrl`, `command`/`args`, `tokenCacheDir`, and pre-registered OAuth fields while keeping headers/env/bearer-token placeholders lazy until runtime. (PR #161 / issue #157, thanks @zxyasfas) - Add `mcporter vault set ` and `mcporter vault clear ` so headless deployments can seed or clear OAuth vault credentials without reproducing mcporter's internal vault-key format. (Issue #156) +### CLI + +- Patch `chrome-devtools-mcp --autoConnect` launches at runtime so `mcporter call chrome-devtools.list_pages` can keep using a logged-in Chrome profile while upstream DevTools-window detection can hang on busy profiles. + ### OAuth - Proactively complete OAuth for configured HTTP servers that allow unauthenticated `initialize`/`listTools` but require credentials for tool calls, and close the local callback server promptly after browser authorization. (PR #159, thanks @Spacefish) diff --git a/README.md b/README.md index 29f2504..6f12931 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Run `mcporter config …` via your package manager (pnpm, npm, npx, etc.) when y }, "chrome-devtools": { "command": "npx", - "args": ["-y", "chrome-devtools-mcp@latest"], + "args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"], "env": { "npm_config_loglevel": "error" }, }, }, @@ -405,6 +405,7 @@ What MCPorter handles for you: - Automatic OAuth token caching in the shared vault (`~/.mcporter/credentials.json`, or `$XDG_DATA_HOME/mcporter/credentials.json` when set) unless you override `tokenCacheDir`. - Stdio commands inherit the directory of the file that defined them (imports or local config). - Import precedence matches the array order; omit `imports` to use the default `["cursor", "claude-code", "claude-desktop", "codex", "windsurf", "opencode", "vscode"]`. +- `chrome-devtools-mcp --autoConnect` receives a small compatibility patch while upstream auto-connect can hang on busy Chrome profiles; set `MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT=1` to opt out. #### OAuth-protected servers diff --git a/config/mcporter.json b/config/mcporter.json index 77971a6..3c703c9 100644 --- a/config/mcporter.json +++ b/config/mcporter.json @@ -3,7 +3,7 @@ "chrome-devtools": { "description": "Chrome DevTools protocol bridge for driving local tabs during debugging or automation.", "command": "npx", - "args": ["-y", "chrome-devtools-mcp@latest"], + "args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"], "env": { "npm_config_loglevel": "error" } diff --git a/docs/logging.md b/docs/logging.md index ebd3d7d..b2b0770 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -38,7 +38,7 @@ Add a logging block inside the server definition (alongside `lifecycle`) when yo "chrome-devtools": { "description": "Chrome DevTools protocol bridge", "command": "npx", - "args": ["-y", "chrome-devtools-mcp@latest"], + "args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"], "lifecycle": "keep-alive", "logging": { "daemon": { "enabled": true } diff --git a/src/chrome-devtools-auto-connect-patch.ts b/src/chrome-devtools-auto-connect-patch.ts new file mode 100644 index 0000000..db92215 --- /dev/null +++ b/src/chrome-devtools-auto-connect-patch.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const MARKER = 'MCPORTER_DEVTOOLS_TIMEOUT_PATCH'; +const HELPER = `// ${MARKER} +const MCPORTER_DEVTOOLS_DETECTION_TIMEOUT = 1_000; +async function mcporterWithTimeout(promise, fallback) { + let timer; + try { + return await Promise.race([ + promise, + new Promise(resolve => { + timer = setTimeout(resolve, MCPORTER_DEVTOOLS_DETECTION_TIMEOUT, fallback); + timer.unref?.(); + }), + ]); + } + finally { + if (timer) { + clearTimeout(timer); + } + } +} +`; + +const DETECTION_BLOCK = `if (await page.hasDevTools()) { + mcpPage.devToolsPage = await page.openDevTools(); + }`; + +const PATCHED_DETECTION_BLOCK = `if (await mcporterWithTimeout(page.hasDevTools(), false)) { + mcpPage.devToolsPage = await mcporterWithTimeout(page.openDevTools(), undefined); + }`; + +patchChromeDevtoolsMcp(); + +export function patchChromeDevtoolsMcp(mainPath = process.argv[1]): void { + if (!mainPath || !mainPath.includes('chrome-devtools-mcp')) { + return; + } + let resolvedMainPath: string; + try { + resolvedMainPath = fs.realpathSync(mainPath); + } catch { + return; + } + if (!resolvedMainPath.endsWith(path.join('bin', 'chrome-devtools-mcp.js'))) { + return; + } + const contextPath = path.resolve(path.dirname(resolvedMainPath), '..', 'McpContext.js'); + let source: string; + try { + source = fs.readFileSync(contextPath, 'utf8'); + } catch { + return; + } + if (source.includes(MARKER)) { + return; + } + if (!source.includes(DETECTION_BLOCK)) { + return; + } + const withHelper = source.replace( + 'const NAVIGATION_TIMEOUT = 10_000;\n', + `const NAVIGATION_TIMEOUT = 10_000;\n${HELPER}` + ); + const patched = withHelper.replace(DETECTION_BLOCK, PATCHED_DETECTION_BLOCK); + try { + fs.writeFileSync(contextPath, patched); + } catch { + return; + } +} diff --git a/src/chrome-devtools-compat.ts b/src/chrome-devtools-compat.ts new file mode 100644 index 0000000..204001c --- /dev/null +++ b/src/chrome-devtools-compat.ts @@ -0,0 +1,162 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const AUTO_CONNECT_FLAGS = new Set(['--autoConnect', '--auto-connect']); +const FALLBACK_PATCH_FILENAME = 'mcporter-chrome-devtools-auto-connect-patch.js'; +const FALLBACK_PATCH_SOURCE = `import fs from 'node:fs'; +import path from 'node:path'; + +const MARKER = 'MCPORTER_DEVTOOLS_TIMEOUT_PATCH'; +const HELPER = \`// \${MARKER} +const MCPORTER_DEVTOOLS_DETECTION_TIMEOUT = 1_000; +async function mcporterWithTimeout(promise, fallback) { + let timer; + try { + return await Promise.race([ + promise, + new Promise(resolve => { + timer = setTimeout(resolve, MCPORTER_DEVTOOLS_DETECTION_TIMEOUT, fallback); + timer.unref?.(); + }), + ]); + } + finally { + if (timer) { + clearTimeout(timer); + } + } +} +\`; + +const DETECTION_BLOCK = \`if (await page.hasDevTools()) { + mcpPage.devToolsPage = await page.openDevTools(); + }\`; + +const PATCHED_DETECTION_BLOCK = \`if (await mcporterWithTimeout(page.hasDevTools(), false)) { + mcpPage.devToolsPage = await mcporterWithTimeout(page.openDevTools(), undefined); + }\`; + +patchChromeDevtoolsMcp(); + +function patchChromeDevtoolsMcp(mainPath = process.argv[1]) { + if (!mainPath || !mainPath.includes('chrome-devtools-mcp')) { + return; + } + let resolvedMainPath; + try { + resolvedMainPath = fs.realpathSync(mainPath); + } catch { + return; + } + if (!resolvedMainPath.endsWith(path.join('bin', 'chrome-devtools-mcp.js'))) { + return; + } + const contextPath = path.resolve(path.dirname(resolvedMainPath), '..', 'McpContext.js'); + let source; + try { + source = fs.readFileSync(contextPath, 'utf8'); + } catch { + return; + } + if (source.includes(MARKER)) { + return; + } + if (!source.includes(DETECTION_BLOCK)) { + return; + } + const withHelper = source.replace( + 'const NAVIGATION_TIMEOUT = 10_000;\\n', + \`const NAVIGATION_TIMEOUT = 10_000;\\n\${HELPER}\` + ); + const patched = withHelper.replace(DETECTION_BLOCK, PATCHED_DETECTION_BLOCK); + try { + fs.writeFileSync(contextPath, patched); + } catch { + return; + } +} +`; + +export interface ChromeDevtoolsCompatResult { + readonly env: Record; + readonly applied: boolean; + readonly patchPath?: string; +} + +export function applyChromeDevtoolsCompat( + env: Record, + command: string, + args: readonly string[] +): ChromeDevtoolsCompatResult { + if (!shouldApplyChromeDevtoolsCompat(command, args, env)) { + return { env, applied: false }; + } + const patchPath = resolveChromeDevtoolsCompatPatchPath(); + if (!patchPath) { + return { env, applied: false }; + } + const importFlag = `--import=${pathToFileURL(patchPath).href}`; + const existingOptions = env.NODE_OPTIONS?.trim(); + if (existingOptions?.includes(importFlag)) { + return { env, applied: true, patchPath }; + } + return { + env: { + ...env, + NODE_OPTIONS: existingOptions ? `${existingOptions} ${importFlag}` : importFlag, + }, + applied: true, + patchPath, + }; +} + +export function shouldApplyChromeDevtoolsCompat( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv | Record = process.env +): boolean { + if (env.MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT === '1') { + return false; + } + const tokens = [command, ...args]; + return tokens.some(isChromeDevtoolsToken) && args.some((arg) => AUTO_CONNECT_FLAGS.has(arg)); +} + +function isChromeDevtoolsToken(token: string): boolean { + return ( + token === 'chrome-devtools-mcp' || + token.startsWith('chrome-devtools-mcp@') || + token.includes('/chrome-devtools-mcp') + ); +} + +export function resolveChromeDevtoolsCompatPatchPath( + candidates = defaultChromeDevtoolsPatchCandidates(), + fallbackDir = os.tmpdir() +): string | undefined { + const existing = candidates.find((candidate) => fs.existsSync(candidate)); + if (existing) { + return existing; + } + return writeFallbackPatch(fallbackDir); +} + +function defaultChromeDevtoolsPatchCandidates(): string[] { + const here = path.dirname(fileURLToPath(import.meta.url)); + return [ + path.join(here, 'chrome-devtools-auto-connect-patch.js'), + path.resolve(here, '..', 'dist', 'chrome-devtools-auto-connect-patch.js'), + ]; +} + +function writeFallbackPatch(fallbackDir: string): string | undefined { + const patchPath = path.join(fallbackDir, FALLBACK_PATCH_FILENAME); + try { + fs.writeFileSync(patchPath, FALLBACK_PATCH_SOURCE, { mode: 0o600 }); + return patchPath; + } catch { + return undefined; + } +} diff --git a/src/runtime/transport.ts b/src/runtime/transport.ts index bb363b7..47cbc1f 100644 --- a/src/runtime/transport.ts +++ b/src/runtime/transport.ts @@ -3,6 +3,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { applyChromeDevtoolsCompat } from '../chrome-devtools-compat.js'; import type { ServerDefinition } from '../config.js'; import { resolveEnvValue, withEnvOverrides } from '../env.js'; import { analyzeConnectionError } from '../error-classifier.js'; @@ -203,11 +204,17 @@ async function createStdioClientContext( resolvedEnvOverrides && Object.keys(resolvedEnvOverrides).length > 0 ? { ...process.env, ...resolvedEnvOverrides } : { ...process.env }; + const command = resolveCommandArgument(definition.command.command); + const commandArgs = resolveCommandArguments(definition.command.args); + const compat = applyChromeDevtoolsCompat(mergedEnv as Record, command, commandArgs); + if (compat.applied) { + logger.info(`Injecting chrome-devtools-mcp --autoConnect compatibility patch from ${compat.patchPath}.`); + } const transport = new StdioClientTransport({ - command: resolveCommandArgument(definition.command.command), - args: resolveCommandArguments(definition.command.args), + command, + args: commandArgs, cwd: definition.command.cwd, - env: mergedEnv, + env: compat.env, }); if (STDIO_TRACE_ENABLED) { attachStdioTraceLogging(transport, definition.name ?? definition.command.command); diff --git a/tests/chrome-devtools-compat.test.ts b/tests/chrome-devtools-compat.test.ts new file mode 100644 index 0000000..1c38e4c --- /dev/null +++ b/tests/chrome-devtools-compat.test.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { patchChromeDevtoolsMcp } from '../src/chrome-devtools-auto-connect-patch.js'; +import { + applyChromeDevtoolsCompat, + resolveChromeDevtoolsCompatPatchPath, + shouldApplyChromeDevtoolsCompat, +} from '../src/chrome-devtools-compat.js'; + +describe('chrome-devtools compatibility', () => { + afterEach(() => { + delete process.env.MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT; + }); + + it('enables the patch for autoConnect chrome-devtools commands', () => { + expect(shouldApplyChromeDevtoolsCompat('npx', ['-y', 'chrome-devtools-mcp@latest', '--autoConnect'])).toBe(true); + expect(shouldApplyChromeDevtoolsCompat('npx', ['-y', 'chrome-devtools-mcp', '--auto-connect'])).toBe(true); + }); + + it('does not patch non-autoConnect commands', () => { + expect(shouldApplyChromeDevtoolsCompat('npx', ['-y', 'chrome-devtools-mcp@latest'])).toBe(false); + }); + + it('allows opting out of the compatibility patch', () => { + process.env.MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT = '1'; + + expect(shouldApplyChromeDevtoolsCompat('npx', ['-y', 'chrome-devtools-mcp@latest', '--autoConnect'])).toBe(false); + }); + + it('allows opting out from the merged server env', () => { + const result = applyChromeDevtoolsCompat({ MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT: '1' }, 'npx', [ + '-y', + 'chrome-devtools-mcp@latest', + '--autoConnect', + ]); + + expect(result).toEqual({ env: { MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT: '1' }, applied: false }); + }); + + it('injects a NODE_OPTIONS import for matching commands', () => { + const result = applyChromeDevtoolsCompat({}, 'npx', ['-y', 'chrome-devtools-mcp@latest', '--autoConnect']); + + expect(result.applied).toBe(true); + expect(result.env.NODE_OPTIONS).toContain('--import=file://'); + expect(result.env.NODE_OPTIONS).toContain('chrome-devtools-auto-connect-patch.js'); + }); + + it('preserves existing NODE_OPTIONS', () => { + const result = applyChromeDevtoolsCompat({ NODE_OPTIONS: '--trace-warnings' }, 'npx', [ + '-y', + 'chrome-devtools-mcp@latest', + '--autoConnect', + ]); + + expect(result.applied).toBe(true); + expect(result.env.NODE_OPTIONS).toContain('--trace-warnings'); + expect(result.env.NODE_OPTIONS).toContain('chrome-devtools-auto-connect-patch.js'); + }); + + it('materializes a JavaScript fallback patch when build output is missing', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cdpmcp-fallback-')); + + const patchPath = resolveChromeDevtoolsCompatPatchPath([], tmp); + + expect(patchPath).toBe(path.join(tmp, 'mcporter-chrome-devtools-auto-connect-patch.js')); + await expect(fs.readFile(patchPath!, 'utf8')).resolves.toContain('MCPORTER_DEVTOOLS_TIMEOUT_PATCH'); + }); + + it('patches an npx .bin symlink target idempotently', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cdpmcp-')); + const packageBinDir = path.join(tmp, 'node_modules/chrome-devtools-mcp/build/src/bin'); + const binDir = path.join(tmp, 'node_modules/.bin'); + const contextPath = path.join(tmp, 'node_modules/chrome-devtools-mcp/build/src/McpContext.js'); + const binPath = path.join(packageBinDir, 'chrome-devtools-mcp.js'); + const shimPath = path.join(binDir, 'chrome-devtools-mcp'); + + await fs.mkdir(packageBinDir, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(binPath, '#!/usr/bin/env node\n'); + await fs.writeFile( + contextPath, + `const NAVIGATION_TIMEOUT = 10_000; +async function detect(page, mcpPage) { + if (await page.hasDevTools()) { + mcpPage.devToolsPage = await page.openDevTools(); + } +} +` + ); + await fs.symlink('../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js', shimPath); + + patchChromeDevtoolsMcp(shimPath); + patchChromeDevtoolsMcp(shimPath); + + const patched = await fs.readFile(contextPath, 'utf8'); + expect(patched.match(/MCPORTER_DEVTOOLS_TIMEOUT_PATCH/g)).toHaveLength(1); + expect(patched).toContain('mcporterWithTimeout(page.hasDevTools(), false)'); + expect(patched).toContain('mcporterWithTimeout(page.openDevTools(), undefined)'); + }); +}); diff --git a/tests/fixtures/mcporter.json b/tests/fixtures/mcporter.json index c4361d1..0c548fc 100644 --- a/tests/fixtures/mcporter.json +++ b/tests/fixtures/mcporter.json @@ -3,7 +3,7 @@ "chrome-devtools": { "description": "Chrome DevTools protocol bridge for driving local tabs during debugging or automation.", "command": "npx", - "args": ["-y", "chrome-devtools-mcp@latest"], + "args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"], "env": { "npm_config_loglevel": "error" }