From 793a55c016c4080c7077a9526a5e2969554d743a Mon Sep 17 00:00:00 2001 From: Bryan Nathan Date: Thu, 23 Jul 2026 07:46:32 +0800 Subject: [PATCH 1/2] fix(mcp): keep child no-proxy entries runtime-safe --- .changeset/quiet-pandas-rest.md | 5 ++ .../agent-core-v2/src/mcpCore/client-stdio.ts | 38 +++++++++++- .../test/mcpCore/client-stdio.test.ts | 37 +++++++++++- packages/agent-core/src/mcp/client-stdio.ts | 43 +++++++++++++- .../agent-core/test/mcp/client-stdio.test.ts | 59 ++++++++++++++++++- 5 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-pandas-rest.md diff --git a/.changeset/quiet-pandas-rest.md b/.changeset/quiet-pandas-rest.md new file mode 100644 index 0000000000..a3279f44b2 --- /dev/null +++ b/.changeset/quiet-pandas-rest.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Python-based MCP servers failing to start under HTTP proxies while preserving Node launchers' IPv6 loopback bypass. diff --git a/packages/agent-core-v2/src/mcpCore/client-stdio.ts b/packages/agent-core-v2/src/mcpCore/client-stdio.ts index 489c365df3..7242920aa3 100644 --- a/packages/agent-core-v2/src/mcpCore/client-stdio.ts +++ b/packages/agent-core-v2/src/mcpCore/client-stdio.ts @@ -54,7 +54,7 @@ export class StdioMcpClient implements MCPClient { this.transport = new StdioClientTransport({ command: config.command, args: config.args, - env: mergeStdioEnv(config.env), + env: mergeStdioEnv(config.env, process.env, config.command, config.args), cwd: resolveStdioCwd(config.cwd, options.defaultCwd), stderr: 'pipe', }); @@ -188,6 +188,8 @@ function resolveStdioCwd(configCwd: string | undefined, defaultCwd: string | und export function mergeStdioEnv( configEnv?: Record, parentEnv: Readonly> = process.env, + command = '', + args: readonly string[] = [], ): Record { const merged: Record = {}; for (const [key, value] of Object.entries(parentEnv)) { @@ -196,5 +198,39 @@ export function mergeStdioEnv( if (configEnv !== undefined) Object.assign(merged, configEnv); Object.assign(merged, proxyEnvForChild(merged)); reconcileChildNoProxy(merged, configEnv); + if ( + !usesNodeEnvProxy(command, args) && + !explicitlyKeepsBracketedIpv6(configEnv) && + !explicitlyKeepsBracketedIpv6(parentEnv) + ) { + for (const key of ['NO_PROXY', 'no_proxy'] as const) { + const value = merged[key]; + if (value !== undefined && value !== '*') { + merged[key] = value + .split(',') + .filter((host) => host.trim() !== '[::1]') + .join(','); + } + } + } return merged; } + +const NODE_ENV_PROXY_COMMAND_RE = + /^(?:node(?:\.exe)?|nodejs(?:\.exe)?|corepack(?:\.cmd)?|npm(?:\.cmd)?|npx(?:\.cmd)?|pnpm(?:\.cmd)?|yarn(?:\.cmd)?|tsx(?:\.cmd)?|ts-node(?:\.cmd)?|.+\.(?:c|m)?js)$/i; +const NODE_ENV_PROXY_WRAPPER_RE = + /^(?:ba|z|fi)?sh$|^(?:env|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?|docker|podman|nix-shell)$/i; +const NODE_ENV_PROXY_ARGUMENT_RE = + /(?:^|[\\/\s"'=])(?:node(?:\.exe)?|nodejs(?:\.exe)?|corepack(?:\.cmd)?|npm(?:\.cmd)?|npx(?:\.cmd)?|pnpm(?:\.cmd)?|yarn(?:\.cmd)?|tsx(?:\.cmd)?|ts-node(?:\.cmd)?|[^\s"']+\.(?:c|m)?js)(?=$|[\s"';])/i; + +function usesNodeEnvProxy(command: string, args: readonly string[]): boolean { + const executable = command.split(/[\\/]/).at(-1) ?? ''; + if (NODE_ENV_PROXY_COMMAND_RE.test(executable)) return true; + return NODE_ENV_PROXY_WRAPPER_RE.test(executable) && args.some((arg) => NODE_ENV_PROXY_ARGUMENT_RE.test(arg)); +} + +function explicitlyKeepsBracketedIpv6(env?: Readonly>): boolean { + return [env?.['no_proxy'], env?.['NO_PROXY']].some((value) => + value?.split(',').some((host) => host.trim() === '[::1]'), + ); +} diff --git a/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts index 7092f0428e..0603e3647f 100644 --- a/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts @@ -304,10 +304,45 @@ describe('mergeStdioEnv', () => { const merged = mergeStdioEnv({ HTTP_PROXY: 'http://corp:3128' }, { PATH: '/usr/bin' }); expect(merged['HTTP_PROXY']).toBe('http://corp:3128'); expect(merged['NODE_USE_ENV_PROXY']).toBe('1'); - expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1'); expect(merged['PATH']).toBe('/usr/bin'); }); + it('keeps bracketed IPv6 loopback for direct and wrapped Node launchers', () => { + for (const { command, args } of [ + { command: 'node', args: [] }, + { command: 'bash', args: ['-c', 'node server.js'] }, + { command: 'cmd.exe', args: ['/c', '"C:\\Program Files\\nodejs\\node.exe" server.js'] }, + ]) { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128' }, + { PATH: '/usr/bin' }, + command, + args, + ); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + } + }); + + it('keeps portable IPv6 syntax for a non-Node shell child', () => { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128' }, + { PATH: '/usr/bin' }, + 'bash', + ['-c', 'python server.py'], + ); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1'); + }); + + it('honors an explicit bracketed IPv6 override', () => { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128', NO_PROXY: 'internal,[::1]' }, + { PATH: '/usr/bin' }, + 'uvx', + ); + expect(merged['NO_PROXY']).toBe('internal,[::1],localhost,127.0.0.1,::1'); + }); + it('does not inject NODE_USE_ENV_PROXY when no proxy is configured', () => { const merged = mergeStdioEnv(undefined, { PATH: '/usr/bin' }); expect(merged['NODE_USE_ENV_PROXY']).toBeUndefined(); diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index 267056d428..5ba7d5aa4a 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -65,7 +65,7 @@ export class StdioMcpClient implements MCPClient { this.transport = new StdioClientTransport({ command: config.command, args: config.args, - env: mergeStdioEnv(config.env), + env: mergeStdioEnv(config.env, process.env, config.command, config.args), cwd: resolveStdioCwd(config.cwd, options.defaultCwd), stderr: 'pipe', }); @@ -252,10 +252,15 @@ function isWindowsAbsolutePath(value: string): boolean { // version that supports the flag — ≥22.21 or ≥24.5). It is computed from the // MERGED env so a proxy declared only in `config.env` is honored too. // `reconcileChildNoProxy` then mirrors a single-casing `NO_PROXY` override onto -// both casings so it isn't shadowed by the injected value. +// both casings so it isn't shadowed by the injected value. Known Node launchers +// retain bracketed IPv6 loopback because Node's env proxy requires it; arbitrary +// runtimes receive the portable bare form unless their config explicitly keeps +// `[::1]`. export function mergeStdioEnv( configEnv?: Record, parentEnv: Readonly> = process.env, + command = '', + args: readonly string[] = [], ): Record { const merged: Record = {}; for (const [key, value] of Object.entries(parentEnv)) { @@ -264,5 +269,39 @@ export function mergeStdioEnv( if (configEnv !== undefined) Object.assign(merged, configEnv); Object.assign(merged, proxyEnvForChild(merged)); reconcileChildNoProxy(merged, configEnv); + if ( + !usesNodeEnvProxy(command, args) && + !explicitlyKeepsBracketedIpv6(configEnv) && + !explicitlyKeepsBracketedIpv6(parentEnv) + ) { + for (const key of ['NO_PROXY', 'no_proxy'] as const) { + const value = merged[key]; + if (value !== undefined && value !== '*') { + merged[key] = value + .split(',') + .filter((host) => host.trim() !== '[::1]') + .join(','); + } + } + } return merged; } + +const NODE_ENV_PROXY_COMMAND_RE = + /^(?:node(?:\.exe)?|nodejs(?:\.exe)?|corepack(?:\.cmd)?|npm(?:\.cmd)?|npx(?:\.cmd)?|pnpm(?:\.cmd)?|yarn(?:\.cmd)?|tsx(?:\.cmd)?|ts-node(?:\.cmd)?|.+\.(?:c|m)?js)$/i; +const NODE_ENV_PROXY_WRAPPER_RE = + /^(?:ba|z|fi)?sh$|^(?:env|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?|docker|podman|nix-shell)$/i; +const NODE_ENV_PROXY_ARGUMENT_RE = + /(?:^|[\\/\s"'=])(?:node(?:\.exe)?|nodejs(?:\.exe)?|corepack(?:\.cmd)?|npm(?:\.cmd)?|npx(?:\.cmd)?|pnpm(?:\.cmd)?|yarn(?:\.cmd)?|tsx(?:\.cmd)?|ts-node(?:\.cmd)?|[^\s"']+\.(?:c|m)?js)(?=$|[\s"';])/i; + +function usesNodeEnvProxy(command: string, args: readonly string[]): boolean { + const executable = command.split(/[\\/]/).at(-1) ?? ''; + if (NODE_ENV_PROXY_COMMAND_RE.test(executable)) return true; + return NODE_ENV_PROXY_WRAPPER_RE.test(executable) && args.some((arg) => NODE_ENV_PROXY_ARGUMENT_RE.test(arg)); +} + +function explicitlyKeepsBracketedIpv6(env?: Readonly>): boolean { + return [env?.['no_proxy'], env?.['NO_PROXY']].some((value) => + value?.split(',').some((host) => host.trim() === '[::1]'), + ); +} diff --git a/packages/agent-core/test/mcp/client-stdio.test.ts b/packages/agent-core/test/mcp/client-stdio.test.ts index ff2c4296dd..6ac8222568 100644 --- a/packages/agent-core/test/mcp/client-stdio.test.ts +++ b/packages/agent-core/test/mcp/client-stdio.test.ts @@ -315,10 +315,67 @@ describe('mergeStdioEnv', () => { const merged = mergeStdioEnv({ HTTP_PROXY: 'http://corp:3128' }, { PATH: '/usr/bin' }); expect(merged['HTTP_PROXY']).toBe('http://corp:3128'); expect(merged['NODE_USE_ENV_PROXY']).toBe('1'); - expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1'); expect(merged['PATH']).toBe('/usr/bin'); }); + it.each(['node', 'NODE.EXE', 'npm.cmd', 'pnpm', '/tools/server.mjs'])( + 'keeps bracketed IPv6 loopback for Node launcher %s', + (command) => { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128' }, + { PATH: '/usr/bin' }, + command, + ); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + }, + ); + + for (const { command, args } of [ + { command: 'bash', args: ['-c', 'node server.js'] }, + { command: '/usr/bin/env', args: ['node', 'server.js'] }, + { command: 'cmd.exe', args: ['/c', '"C:\\Program Files\\nodejs\\node.exe" server.js'] }, + { command: 'docker', args: ['exec', 'app', 'node', 'server.js'] }, + ]) { + it(`keeps bracketed IPv6 loopback for wrapped Node launcher ${command}`, () => { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128' }, + { PATH: '/usr/bin' }, + command, + args, + ); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + }); + } + + it('keeps portable IPv6 syntax for a non-Node shell child', () => { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128' }, + { PATH: '/usr/bin' }, + 'bash', + ['-c', 'python server.py'], + ); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1'); + }); + + it('honors an explicit bracketed IPv6 override for other launchers', () => { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128', NO_PROXY: 'internal,[::1]' }, + { PATH: '/usr/bin' }, + 'uvx', + ); + expect(merged['NO_PROXY']).toBe('internal,[::1],localhost,127.0.0.1,::1'); + }); + + it('honors a bracketed IPv6 entry inherited from the parent', () => { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128' }, + { PATH: '/usr/bin', NO_PROXY: 'parent,[::1]' }, + 'custom-node-wrapper', + ); + expect(merged['NO_PROXY']).toBe('parent,[::1],localhost,127.0.0.1,::1'); + }); + it('does not inject NODE_USE_ENV_PROXY when no proxy is configured', () => { const merged = mergeStdioEnv(undefined, { PATH: '/usr/bin' }); expect(merged['NODE_USE_ENV_PROXY']).toBeUndefined(); From 859f0bdb65e4e99206f607cdea825f9b2665af22 Mon Sep 17 00:00:00 2001 From: Bryan Nathan Date: Fri, 24 Jul 2026 06:53:59 +0800 Subject: [PATCH 2/2] fix(mcp): recognize Node shebang launchers --- .../agent-core-v2/src/mcpCore/client-stdio.ts | 50 ++++++++++++++++-- .../test/mcpCore/client-stdio.test.ts | 26 +++++++++- packages/agent-core/src/mcp/client-stdio.ts | 51 ++++++++++++++++--- .../agent-core/test/mcp/client-stdio.test.ts | 26 +++++++++- 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/packages/agent-core-v2/src/mcpCore/client-stdio.ts b/packages/agent-core-v2/src/mcpCore/client-stdio.ts index 7242920aa3..2364bd3e0d 100644 --- a/packages/agent-core-v2/src/mcpCore/client-stdio.ts +++ b/packages/agent-core-v2/src/mcpCore/client-stdio.ts @@ -7,6 +7,8 @@ import type { McpServerStdioConfig } from './config-schema'; import { proxyEnvForChild, reconcileChildNoProxy } from '#/_base/utils/proxy'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { closeSync, openSync, readSync } from 'node:fs'; +import { delimiter } from 'node:path'; import { isAbsolute, resolve } from 'pathe'; import { @@ -51,11 +53,12 @@ export class StdioMcpClient implements MCPClient { if (config.executor !== undefined && config.executor !== 'local') { throw new Error2(ErrorCodes.NOT_IMPLEMENTED, `MCP stdio executor '${config.executor}' is not yet implemented`); } + const cwd = resolveStdioCwd(config.cwd, options.defaultCwd); this.transport = new StdioClientTransport({ command: config.command, args: config.args, - env: mergeStdioEnv(config.env, process.env, config.command, config.args), - cwd: resolveStdioCwd(config.cwd, options.defaultCwd), + env: mergeStdioEnv(config.env, process.env, config.command, config.args, cwd), + cwd, stderr: 'pipe', }); this.transport.stderr?.on('data', (chunk: Buffer | string) => { @@ -190,6 +193,7 @@ export function mergeStdioEnv( parentEnv: Readonly> = process.env, command = '', args: readonly string[] = [], + cwd?: string, ): Record { const merged: Record = {}; for (const [key, value] of Object.entries(parentEnv)) { @@ -199,7 +203,7 @@ export function mergeStdioEnv( Object.assign(merged, proxyEnvForChild(merged)); reconcileChildNoProxy(merged, configEnv); if ( - !usesNodeEnvProxy(command, args) && + !usesNodeEnvProxy(command, args, merged, cwd) && !explicitlyKeepsBracketedIpv6(configEnv) && !explicitlyKeepsBracketedIpv6(parentEnv) ) { @@ -222,11 +226,47 @@ const NODE_ENV_PROXY_WRAPPER_RE = /^(?:ba|z|fi)?sh$|^(?:env|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?|docker|podman|nix-shell)$/i; const NODE_ENV_PROXY_ARGUMENT_RE = /(?:^|[\\/\s"'=])(?:node(?:\.exe)?|nodejs(?:\.exe)?|corepack(?:\.cmd)?|npm(?:\.cmd)?|npx(?:\.cmd)?|pnpm(?:\.cmd)?|yarn(?:\.cmd)?|tsx(?:\.cmd)?|ts-node(?:\.cmd)?|[^\s"']+\.(?:c|m)?js)(?=$|[\s"';])/i; +const NODE_SHEBANG_RE = /^#![^\r\n]*(?:[/\s])node(?:js)?(?:\s|$)/i; -function usesNodeEnvProxy(command: string, args: readonly string[]): boolean { +function usesNodeEnvProxy( + command: string, + args: readonly string[], + env: Readonly>, + cwd?: string, +): boolean { const executable = command.split(/[\\/]/).at(-1) ?? ''; if (NODE_ENV_PROXY_COMMAND_RE.test(executable)) return true; - return NODE_ENV_PROXY_WRAPPER_RE.test(executable) && args.some((arg) => NODE_ENV_PROXY_ARGUMENT_RE.test(arg)); + if (NODE_ENV_PROXY_WRAPPER_RE.test(executable) && args.some((arg) => NODE_ENV_PROXY_ARGUMENT_RE.test(arg))) { + return true; + } + return hasNodeShebang(command, env, cwd); +} + +function hasNodeShebang( + command: string, + env: Readonly>, + cwd?: string, +): boolean { + const baseCwd = cwd ?? process.cwd(); + const candidates = /[\\/]/.test(command) + ? [resolve(baseCwd, command)] + : (env['PATH'] ?? env['Path'] ?? '') + .split(delimiter) + .filter(Boolean) + .map((directory) => resolve(baseCwd, directory, command)); + for (const candidate of candidates) { + let descriptor: number | undefined; + try { + descriptor = openSync(candidate, 'r'); + const buffer = Buffer.alloc(256); + const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0); + if (NODE_SHEBANG_RE.test(buffer.toString('utf8', 0, bytesRead))) return true; + } catch { + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + } + return false; } function explicitlyKeepsBracketedIpv6(env?: Readonly>): boolean { diff --git a/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts index 0603e3647f..cd2fde6a08 100644 --- a/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, realpathSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -324,6 +324,30 @@ describe('mergeStdioEnv', () => { } }); + it('keeps bracketed IPv6 loopback for extensionless Node shebang launchers', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-mcp-v2-node-bin-')); + const launcher = join(dir, 'my-mcp'); + writeFileSync(launcher, '#!/usr/bin/env node\n'); + try { + for (const { command, cwd } of [ + { command: launcher, cwd: undefined }, + { command: 'my-mcp', cwd: undefined }, + { command: './my-mcp', cwd: dir }, + ]) { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128' }, + { PATH: dir }, + command, + [], + cwd, + ); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it('keeps portable IPv6 syntax for a non-Node shell child', () => { const merged = mergeStdioEnv( { HTTP_PROXY: 'http://corp:3128' }, diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index 5ba7d5aa4a..c58ba08395 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -3,7 +3,8 @@ import type { McpServerStdioConfig } from '#/config/schema'; import { proxyEnvForChild, reconcileChildNoProxy } from '#/utils/proxy'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { win32 } from 'node:path'; +import { closeSync, openSync, readSync } from 'node:fs'; +import { delimiter, win32 } from 'node:path'; import { isAbsolute, resolve } from 'pathe'; import { @@ -62,11 +63,12 @@ export class StdioMcpClient implements MCPClient { if (config.executor !== undefined && config.executor !== 'local') { throw new KimiError(ErrorCodes.NOT_IMPLEMENTED, `MCP stdio executor '${config.executor}' is not yet implemented`); } + const cwd = resolveStdioCwd(config.cwd, options.defaultCwd); this.transport = new StdioClientTransport({ command: config.command, args: config.args, - env: mergeStdioEnv(config.env, process.env, config.command, config.args), - cwd: resolveStdioCwd(config.cwd, options.defaultCwd), + env: mergeStdioEnv(config.env, process.env, config.command, config.args, cwd), + cwd, stderr: 'pipe', }); // `stderr: 'pipe'` means we MUST drain the stream — otherwise the child @@ -261,6 +263,7 @@ export function mergeStdioEnv( parentEnv: Readonly> = process.env, command = '', args: readonly string[] = [], + cwd?: string, ): Record { const merged: Record = {}; for (const [key, value] of Object.entries(parentEnv)) { @@ -270,7 +273,7 @@ export function mergeStdioEnv( Object.assign(merged, proxyEnvForChild(merged)); reconcileChildNoProxy(merged, configEnv); if ( - !usesNodeEnvProxy(command, args) && + !usesNodeEnvProxy(command, args, merged, cwd) && !explicitlyKeepsBracketedIpv6(configEnv) && !explicitlyKeepsBracketedIpv6(parentEnv) ) { @@ -293,11 +296,47 @@ const NODE_ENV_PROXY_WRAPPER_RE = /^(?:ba|z|fi)?sh$|^(?:env|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?|docker|podman|nix-shell)$/i; const NODE_ENV_PROXY_ARGUMENT_RE = /(?:^|[\\/\s"'=])(?:node(?:\.exe)?|nodejs(?:\.exe)?|corepack(?:\.cmd)?|npm(?:\.cmd)?|npx(?:\.cmd)?|pnpm(?:\.cmd)?|yarn(?:\.cmd)?|tsx(?:\.cmd)?|ts-node(?:\.cmd)?|[^\s"']+\.(?:c|m)?js)(?=$|[\s"';])/i; +const NODE_SHEBANG_RE = /^#![^\r\n]*(?:[/\s])node(?:js)?(?:\s|$)/i; -function usesNodeEnvProxy(command: string, args: readonly string[]): boolean { +function usesNodeEnvProxy( + command: string, + args: readonly string[], + env: Readonly>, + cwd?: string, +): boolean { const executable = command.split(/[\\/]/).at(-1) ?? ''; if (NODE_ENV_PROXY_COMMAND_RE.test(executable)) return true; - return NODE_ENV_PROXY_WRAPPER_RE.test(executable) && args.some((arg) => NODE_ENV_PROXY_ARGUMENT_RE.test(arg)); + if (NODE_ENV_PROXY_WRAPPER_RE.test(executable) && args.some((arg) => NODE_ENV_PROXY_ARGUMENT_RE.test(arg))) { + return true; + } + return hasNodeShebang(command, env, cwd); +} + +function hasNodeShebang( + command: string, + env: Readonly>, + cwd?: string, +): boolean { + const baseCwd = cwd ?? process.cwd(); + const candidates = /[\\/]/.test(command) + ? [resolve(baseCwd, command)] + : (env['PATH'] ?? env['Path'] ?? '') + .split(delimiter) + .filter(Boolean) + .map((directory) => resolve(baseCwd, directory, command)); + for (const candidate of candidates) { + let descriptor: number | undefined; + try { + descriptor = openSync(candidate, 'r'); + const buffer = Buffer.alloc(256); + const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0); + if (NODE_SHEBANG_RE.test(buffer.toString('utf8', 0, bytesRead))) return true; + } catch { + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + } + return false; } function explicitlyKeepsBracketedIpv6(env?: Readonly>): boolean { diff --git a/packages/agent-core/test/mcp/client-stdio.test.ts b/packages/agent-core/test/mcp/client-stdio.test.ts index 6ac8222568..4346aadace 100644 --- a/packages/agent-core/test/mcp/client-stdio.test.ts +++ b/packages/agent-core/test/mcp/client-stdio.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, realpathSync } from 'node:fs'; +import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'pathe'; @@ -331,6 +331,30 @@ describe('mergeStdioEnv', () => { }, ); + it('keeps bracketed IPv6 loopback for extensionless Node shebang launchers', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-mcp-node-bin-')); + const launcher = join(dir, 'my-mcp'); + writeFileSync(launcher, '#!/usr/bin/env node\n'); + try { + for (const { command, cwd } of [ + { command: launcher, cwd: undefined }, + { command: 'my-mcp', cwd: undefined }, + { command: './my-mcp', cwd: dir }, + ]) { + const merged = mergeStdioEnv( + { HTTP_PROXY: 'http://corp:3128' }, + { PATH: dir }, + command, + [], + cwd, + ); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + for (const { command, args } of [ { command: 'bash', args: ['-c', 'node server.js'] }, { command: '/usr/bin/env', args: ['node', 'server.js'] },