Skip to content

Commit 8d962fb

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/mcporter-serve
# Conflicts: # CHANGELOG.md
2 parents 907ba78 + eee954e commit 8d962fb

9 files changed

Lines changed: 353 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# mcporter Changelog
22

3-
## [Unreleased]
3+
## [0.11.0] - Unreleased
44

55
### Config
66

@@ -10,6 +10,7 @@
1010
### CLI
1111

1212
- Add `mcporter serve`, exposing daemon-managed keep-alive servers as one MCP bridge with readable `server__tool` names for stdio and Streamable HTTP clients. (PR #172, thanks @zm2231)
13+
- 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.
1314

1415
### OAuth
1516

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ Run `mcporter config …` via your package manager (pnpm, npm, npx, etc.) when y
392392
},
393393
"chrome-devtools": {
394394
"command": "npx",
395-
"args": ["-y", "chrome-devtools-mcp@latest"],
395+
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
396396
"env": { "npm_config_loglevel": "error" },
397397
},
398398
},
@@ -406,6 +406,7 @@ What MCPorter handles for you:
406406
- Automatic OAuth token caching in the shared vault (`~/.mcporter/credentials.json`, or `$XDG_DATA_HOME/mcporter/credentials.json` when set) unless you override `tokenCacheDir`.
407407
- Stdio commands inherit the directory of the file that defined them (imports or local config).
408408
- Import precedence matches the array order; omit `imports` to use the default `["cursor", "claude-code", "claude-desktop", "codex", "windsurf", "opencode", "vscode"]`.
409+
- `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.
409410

410411
#### OAuth-protected servers
411412

config/mcporter.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"chrome-devtools": {
44
"description": "Chrome DevTools protocol bridge for driving local tabs during debugging or automation.",
55
"command": "npx",
6-
"args": ["-y", "chrome-devtools-mcp@latest"],
6+
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
77
"env": {
88
"npm_config_loglevel": "error"
99
}

docs/logging.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Add a logging block inside the server definition (alongside `lifecycle`) when yo
3838
"chrome-devtools": {
3939
"description": "Chrome DevTools protocol bridge",
4040
"command": "npx",
41-
"args": ["-y", "chrome-devtools-mcp@latest"],
41+
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect"],
4242
"lifecycle": "keep-alive",
4343
"logging": {
4444
"daemon": { "enabled": true }
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
const MARKER = 'MCPORTER_DEVTOOLS_TIMEOUT_PATCH';
5+
const HELPER = `// ${MARKER}
6+
const MCPORTER_DEVTOOLS_DETECTION_TIMEOUT = 1_000;
7+
async function mcporterWithTimeout(promise, fallback) {
8+
let timer;
9+
try {
10+
return await Promise.race([
11+
promise,
12+
new Promise(resolve => {
13+
timer = setTimeout(resolve, MCPORTER_DEVTOOLS_DETECTION_TIMEOUT, fallback);
14+
timer.unref?.();
15+
}),
16+
]);
17+
}
18+
finally {
19+
if (timer) {
20+
clearTimeout(timer);
21+
}
22+
}
23+
}
24+
`;
25+
26+
const DETECTION_BLOCK = `if (await page.hasDevTools()) {
27+
mcpPage.devToolsPage = await page.openDevTools();
28+
}`;
29+
30+
const PATCHED_DETECTION_BLOCK = `if (await mcporterWithTimeout(page.hasDevTools(), false)) {
31+
mcpPage.devToolsPage = await mcporterWithTimeout(page.openDevTools(), undefined);
32+
}`;
33+
34+
patchChromeDevtoolsMcp();
35+
36+
export function patchChromeDevtoolsMcp(mainPath = process.argv[1]): void {
37+
if (!mainPath || !mainPath.includes('chrome-devtools-mcp')) {
38+
return;
39+
}
40+
let resolvedMainPath: string;
41+
try {
42+
resolvedMainPath = fs.realpathSync(mainPath);
43+
} catch {
44+
return;
45+
}
46+
if (!resolvedMainPath.endsWith(path.join('bin', 'chrome-devtools-mcp.js'))) {
47+
return;
48+
}
49+
const contextPath = path.resolve(path.dirname(resolvedMainPath), '..', 'McpContext.js');
50+
let source: string;
51+
try {
52+
source = fs.readFileSync(contextPath, 'utf8');
53+
} catch {
54+
return;
55+
}
56+
if (source.includes(MARKER)) {
57+
return;
58+
}
59+
if (!source.includes(DETECTION_BLOCK)) {
60+
return;
61+
}
62+
const withHelper = source.replace(
63+
'const NAVIGATION_TIMEOUT = 10_000;\n',
64+
`const NAVIGATION_TIMEOUT = 10_000;\n${HELPER}`
65+
);
66+
const patched = withHelper.replace(DETECTION_BLOCK, PATCHED_DETECTION_BLOCK);
67+
try {
68+
fs.writeFileSync(contextPath, patched);
69+
} catch {
70+
return;
71+
}
72+
}

src/chrome-devtools-compat.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { fileURLToPath, pathToFileURL } from 'node:url';
5+
6+
const AUTO_CONNECT_FLAGS = new Set(['--autoConnect', '--auto-connect']);
7+
const FALLBACK_PATCH_FILENAME = 'mcporter-chrome-devtools-auto-connect-patch.js';
8+
const FALLBACK_PATCH_SOURCE = `import fs from 'node:fs';
9+
import path from 'node:path';
10+
11+
const MARKER = 'MCPORTER_DEVTOOLS_TIMEOUT_PATCH';
12+
const HELPER = \`// \${MARKER}
13+
const MCPORTER_DEVTOOLS_DETECTION_TIMEOUT = 1_000;
14+
async function mcporterWithTimeout(promise, fallback) {
15+
let timer;
16+
try {
17+
return await Promise.race([
18+
promise,
19+
new Promise(resolve => {
20+
timer = setTimeout(resolve, MCPORTER_DEVTOOLS_DETECTION_TIMEOUT, fallback);
21+
timer.unref?.();
22+
}),
23+
]);
24+
}
25+
finally {
26+
if (timer) {
27+
clearTimeout(timer);
28+
}
29+
}
30+
}
31+
\`;
32+
33+
const DETECTION_BLOCK = \`if (await page.hasDevTools()) {
34+
mcpPage.devToolsPage = await page.openDevTools();
35+
}\`;
36+
37+
const PATCHED_DETECTION_BLOCK = \`if (await mcporterWithTimeout(page.hasDevTools(), false)) {
38+
mcpPage.devToolsPage = await mcporterWithTimeout(page.openDevTools(), undefined);
39+
}\`;
40+
41+
patchChromeDevtoolsMcp();
42+
43+
function patchChromeDevtoolsMcp(mainPath = process.argv[1]) {
44+
if (!mainPath || !mainPath.includes('chrome-devtools-mcp')) {
45+
return;
46+
}
47+
let resolvedMainPath;
48+
try {
49+
resolvedMainPath = fs.realpathSync(mainPath);
50+
} catch {
51+
return;
52+
}
53+
if (!resolvedMainPath.endsWith(path.join('bin', 'chrome-devtools-mcp.js'))) {
54+
return;
55+
}
56+
const contextPath = path.resolve(path.dirname(resolvedMainPath), '..', 'McpContext.js');
57+
let source;
58+
try {
59+
source = fs.readFileSync(contextPath, 'utf8');
60+
} catch {
61+
return;
62+
}
63+
if (source.includes(MARKER)) {
64+
return;
65+
}
66+
if (!source.includes(DETECTION_BLOCK)) {
67+
return;
68+
}
69+
const withHelper = source.replace(
70+
'const NAVIGATION_TIMEOUT = 10_000;\\n',
71+
\`const NAVIGATION_TIMEOUT = 10_000;\\n\${HELPER}\`
72+
);
73+
const patched = withHelper.replace(DETECTION_BLOCK, PATCHED_DETECTION_BLOCK);
74+
try {
75+
fs.writeFileSync(contextPath, patched);
76+
} catch {
77+
return;
78+
}
79+
}
80+
`;
81+
82+
export interface ChromeDevtoolsCompatResult {
83+
readonly env: Record<string, string>;
84+
readonly applied: boolean;
85+
readonly patchPath?: string;
86+
}
87+
88+
export function applyChromeDevtoolsCompat(
89+
env: Record<string, string>,
90+
command: string,
91+
args: readonly string[]
92+
): ChromeDevtoolsCompatResult {
93+
if (!shouldApplyChromeDevtoolsCompat(command, args, env)) {
94+
return { env, applied: false };
95+
}
96+
const patchPath = resolveChromeDevtoolsCompatPatchPath();
97+
if (!patchPath) {
98+
return { env, applied: false };
99+
}
100+
const importFlag = `--import=${pathToFileURL(patchPath).href}`;
101+
const existingOptions = env.NODE_OPTIONS?.trim();
102+
if (existingOptions?.includes(importFlag)) {
103+
return { env, applied: true, patchPath };
104+
}
105+
return {
106+
env: {
107+
...env,
108+
NODE_OPTIONS: existingOptions ? `${existingOptions} ${importFlag}` : importFlag,
109+
},
110+
applied: true,
111+
patchPath,
112+
};
113+
}
114+
115+
export function shouldApplyChromeDevtoolsCompat(
116+
command: string,
117+
args: readonly string[],
118+
env: NodeJS.ProcessEnv | Record<string, string> = process.env
119+
): boolean {
120+
if (env.MCPORTER_DISABLE_CHROME_DEVTOOLS_COMPAT === '1') {
121+
return false;
122+
}
123+
const tokens = [command, ...args];
124+
return tokens.some(isChromeDevtoolsToken) && args.some((arg) => AUTO_CONNECT_FLAGS.has(arg));
125+
}
126+
127+
function isChromeDevtoolsToken(token: string): boolean {
128+
return (
129+
token === 'chrome-devtools-mcp' ||
130+
token.startsWith('chrome-devtools-mcp@') ||
131+
token.includes('/chrome-devtools-mcp')
132+
);
133+
}
134+
135+
export function resolveChromeDevtoolsCompatPatchPath(
136+
candidates = defaultChromeDevtoolsPatchCandidates(),
137+
fallbackDir = os.tmpdir()
138+
): string | undefined {
139+
const existing = candidates.find((candidate) => fs.existsSync(candidate));
140+
if (existing) {
141+
return existing;
142+
}
143+
return writeFallbackPatch(fallbackDir);
144+
}
145+
146+
function defaultChromeDevtoolsPatchCandidates(): string[] {
147+
const here = path.dirname(fileURLToPath(import.meta.url));
148+
return [
149+
path.join(here, 'chrome-devtools-auto-connect-patch.js'),
150+
path.resolve(here, '..', 'dist', 'chrome-devtools-auto-connect-patch.js'),
151+
];
152+
}
153+
154+
function writeFallbackPatch(fallbackDir: string): string | undefined {
155+
const patchPath = path.join(fallbackDir, FALLBACK_PATCH_FILENAME);
156+
try {
157+
fs.writeFileSync(patchPath, FALLBACK_PATCH_SOURCE, { mode: 0o600 });
158+
return patchPath;
159+
} catch {
160+
return undefined;
161+
}
162+
}

src/runtime/transport.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
33
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
44
import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
55
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
6+
import { applyChromeDevtoolsCompat } from '../chrome-devtools-compat.js';
67
import type { ServerDefinition } from '../config.js';
78
import { resolveEnvValue, withEnvOverrides } from '../env.js';
89
import { analyzeConnectionError } from '../error-classifier.js';
@@ -203,11 +204,17 @@ async function createStdioClientContext(
203204
resolvedEnvOverrides && Object.keys(resolvedEnvOverrides).length > 0
204205
? { ...process.env, ...resolvedEnvOverrides }
205206
: { ...process.env };
207+
const command = resolveCommandArgument(definition.command.command);
208+
const commandArgs = resolveCommandArguments(definition.command.args);
209+
const compat = applyChromeDevtoolsCompat(mergedEnv as Record<string, string>, command, commandArgs);
210+
if (compat.applied) {
211+
logger.info(`Injecting chrome-devtools-mcp --autoConnect compatibility patch from ${compat.patchPath}.`);
212+
}
206213
const transport = new StdioClientTransport({
207-
command: resolveCommandArgument(definition.command.command),
208-
args: resolveCommandArguments(definition.command.args),
214+
command,
215+
args: commandArgs,
209216
cwd: definition.command.cwd,
210-
env: mergedEnv,
217+
env: compat.env,
211218
});
212219
if (STDIO_TRACE_ENABLED) {
213220
attachStdioTraceLogging(transport, definition.name ?? definition.command.command);

0 commit comments

Comments
 (0)