Merge remote-tracking branch 'origin/main' into feat/mcporter-serve
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
8d962fbd79
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
72
src/chrome-devtools-auto-connect-patch.ts
Normal file
72
src/chrome-devtools-auto-connect-patch.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
162
src/chrome-devtools-compat.ts
Normal file
162
src/chrome-devtools-compat.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
102
tests/chrome-devtools-compat.test.ts
Normal file
102
tests/chrome-devtools-compat.test.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
2
tests/fixtures/mcporter.json
vendored
2
tests/fixtures/mcporter.json
vendored
@ -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"
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user