Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-pandas-rest.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 78 additions & 2 deletions packages/agent-core-v2/src/mcpCore/client-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
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) => {
Expand Down Expand Up @@ -188,6 +191,9 @@ function resolveStdioCwd(configCwd: string | undefined, defaultCwd: string | und
export function mergeStdioEnv(
configEnv?: Record<string, string>,
parentEnv: Readonly<Record<string, string | undefined>> = process.env,
command = '',
args: readonly string[] = [],
cwd?: string,
): Record<string, string> {
const merged: Record<string, string> = {};
for (const [key, value] of Object.entries(parentEnv)) {
Expand All @@ -196,5 +202,75 @@ export function mergeStdioEnv(
if (configEnv !== undefined) Object.assign(merged, configEnv);
Object.assign(merged, proxyEnvForChild(merged));
reconcileChildNoProxy(merged, configEnv);
if (
!usesNodeEnvProxy(command, args, merged, cwd) &&
!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;
const NODE_SHEBANG_RE = /^#![^\r\n]*(?:[/\s])node(?:js)?(?:\s|$)/i;

function usesNodeEnvProxy(
command: string,
args: readonly string[],
env: Readonly<Record<string, string | undefined>>,
cwd?: string,
): boolean {
const executable = command.split(/[\\/]/).at(-1) ?? '';
if (NODE_ENV_PROXY_COMMAND_RE.test(executable)) return true;
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<Record<string, string | undefined>>,
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<Record<string, string | undefined>>): boolean {
return [env?.['no_proxy'], env?.['NO_PROXY']].some((value) =>
value?.split(',').some((host) => host.trim() === '[::1]'),
);
}
63 changes: 61 additions & 2 deletions packages/agent-core-v2/test/mcpCore/client-stdio.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -304,10 +304,69 @@ 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 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' },
{ 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();
Expand Down
86 changes: 82 additions & 4 deletions packages/agent-core/src/mcp/client-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
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
Expand Down Expand Up @@ -252,10 +254,16 @@ 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<string, string>,
parentEnv: Readonly<Record<string, string | undefined>> = process.env,
command = '',
args: readonly string[] = [],
cwd?: string,
): Record<string, string> {
const merged: Record<string, string> = {};
for (const [key, value] of Object.entries(parentEnv)) {
Expand All @@ -264,5 +272,75 @@ export function mergeStdioEnv(
if (configEnv !== undefined) Object.assign(merged, configEnv);
Object.assign(merged, proxyEnvForChild(merged));
reconcileChildNoProxy(merged, configEnv);
if (
!usesNodeEnvProxy(command, args, merged, cwd) &&
!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;
const NODE_SHEBANG_RE = /^#![^\r\n]*(?:[/\s])node(?:js)?(?:\s|$)/i;

function usesNodeEnvProxy(
command: string,
args: readonly string[],
env: Readonly<Record<string, string | undefined>>,
cwd?: string,
): boolean {
const executable = command.split(/[\\/]/).at(-1) ?? '';
if (NODE_ENV_PROXY_COMMAND_RE.test(executable)) return true;
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<Record<string, string | undefined>>,
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<Record<string, string | undefined>>): boolean {
return [env?.['no_proxy'], env?.['NO_PROXY']].some((value) =>
value?.split(',').some((host) => host.trim() === '[::1]'),
);
}
Loading