Merge remote-tracking branch 'origin/main' into feat/mcporter-serve

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Peter Steinberger 2026-05-14 13:40:29 +01:00
commit 8d962fbd79
No known key found for this signature in database
9 changed files with 353 additions and 8 deletions

View File

@ -1,6 +1,6 @@
# mcporter Changelog
## [Unreleased]
## [0.11.0] - Unreleased
### Config
@ -10,6 +10,7 @@
### CLI
- 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)
- 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

View File

@ -392,7 +392,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" },
},
},
@ -406,6 +406,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

View File

@ -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"
}

View File

@ -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 }

View File

@ -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;
}
}

View File

@ -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<string, string>;
readonly applied: boolean;
readonly patchPath?: string;
}
export function applyChromeDevtoolsCompat(
env: Record<string, string>,
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<string, string> = 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;
}
}

View File

@ -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<string, string>, 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);

View File

@ -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)');
});
});

View File

@ -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"
}