Skip to content
Closed
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/mcp-child-noproxy-ipv6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix spawned child processes (e.g. stdio MCP servers) receiving a bracketed `[::1]` entry in `NO_PROXY`, which crashes Python httpx-based MCP servers with `Invalid port: ':1]'` whenever a proxy is configured; the child env now carries the unbracketed loopback list while the in-process dispatcher keeps the bracketed form needed by undici.
21 changes: 17 additions & 4 deletions packages/agent-core-v2/src/_base/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ export interface SocksProxyConfig {

const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1', '[::1]'] as const;

// Spawned child processes get the loopback list WITHOUT the bracketed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move v2 explanatory comment into the module header

In packages/agent-core-v2, the scoped comment convention allows explanatory comments only in the top-of-file /** */ block and explicitly forbids comments beside statements; this new inline block above CHILD_LOOPBACK_NO_PROXY violates that convention, so please move the child-process rationale into the module header or otherwise avoid a statement-adjacent comment.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L15-L19

Useful? React with 👍 / 👎.

// `[::1]`: Python's httpx (the HTTP client behind many MCP servers, e.g.
// fastmcp-based ones) crashes on it with `Invalid port: ':1]'` while building
// its proxy mounts, which kills every Python stdio MCP server at startup
// whenever a proxy is configured. The bracketed form only exists for undici
// 7.x's NO_PROXY parser (our own in-process dispatcher); for a child, losing
// the bypass for literal `http://[::1]` URLs is a far smaller risk than
// breaking every Python child outright.
const CHILD_LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1'] as const;

const SOCKS_SCHEMES = new Set(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']);

function schemeOf(value: string): string | undefined {
Expand Down Expand Up @@ -90,14 +100,17 @@ export function isProxyConfigured(env: Env): boolean {
return hasHttpProxy(env) || resolveSocksProxy(env) !== undefined;
}

export function resolveNoProxy(env: Env): string {
export function resolveNoProxy(
env: Env,
loopbackHosts: readonly string[] = LOOPBACK_NO_PROXY,
): string {
const raw = [env['no_proxy'], env['NO_PROXY']].find((value) => (value?.trim() ?? '').length > 0) ?? '';
const hosts = raw
.split(',')
.map((host) => host.trim())
.filter((host) => host.length > 0);
if (hosts.includes('*')) return '*';
for (const loopback of LOOPBACK_NO_PROXY) {
for (const loopback of loopbackHosts) {
if (!hosts.includes(loopback)) hosts.push(loopback);
}
return hosts.join(',');
Expand Down Expand Up @@ -232,7 +245,7 @@ export function installGlobalProxyDispatcher(

export function proxyEnvForChild(env: Env): Record<string, string> {
if (!hasHttpProxy(env)) return {};
const noProxy = resolveNoProxy(env);
const noProxy = resolveNoProxy(env, CHILD_LOOPBACK_NO_PROXY);
const result: Record<string, string> = {
NODE_USE_ENV_PROXY: '1',
NO_PROXY: noProxy,
Expand All @@ -258,7 +271,7 @@ export function reconcileChildNoProxy(
(value) => (value?.trim() ?? '').length > 0,
);
if (override === undefined) return;
const noProxy = resolveNoProxy({ no_proxy: override, NO_PROXY: override });
const noProxy = resolveNoProxy({ no_proxy: override, NO_PROXY: override }, CHILD_LOOPBACK_NO_PROXY);
childEnv['NO_PROXY'] = noProxy;
childEnv['no_proxy'] = noProxy;
}
8 changes: 4 additions & 4 deletions packages/agent-core-v2/test/_base/utils/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ describe('proxy utilities', () => {
expect(proxyEnvForChild({ ALL_PROXY: 'socks5://127.0.0.1:1080' })).toEqual({});
expect(proxyEnvForChild({ HTTP_PROXY: 'http://p:3128', NO_PROXY: 'corp' })).toEqual({
NODE_USE_ENV_PROXY: '1',
NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]',
no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]',
NO_PROXY: 'corp,localhost,127.0.0.1,::1',
no_proxy: 'corp,localhost,127.0.0.1,::1',
HTTP_PROXY: 'http://p:3128',
http_proxy: 'http://p:3128',
});
Expand All @@ -141,7 +141,7 @@ describe('proxy utilities', () => {
no_proxy: 'aug',
};
reconcileChildNoProxy(childEnv, { no_proxy: '', NO_PROXY: 'real.corp' });
expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]');
expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]');
expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1');
expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1');
});
});
2 changes: 1 addition & 1 deletion packages/agent-core-v2/test/mcpCore/client-stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ 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');
});

Expand Down
41 changes: 21 additions & 20 deletions packages/agent-core-v2/test/mcpCore/stubs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { fileURLToPath } from 'node:url';

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
Expand All @@ -17,26 +18,26 @@ import type {
ToolExecution,
} from '#/tool/toolContract';

export const fixturesDir = new URL('./fixtures/', import.meta.url).pathname;
export const stdioFixture = new URL('./fixtures/mock-stdio-server.mjs', import.meta.url).pathname;
export const cwdStdioFixture = new URL('./fixtures/cwd-stdio-server.mjs', import.meta.url).pathname;
export const slowStdioFixture = new URL('./fixtures/slow-stdio-server.mjs', import.meta.url).pathname;
export const slowToolStdioFixture = new URL(
'./fixtures/slow-tool-stdio-server.mjs',
import.meta.url,
).pathname;
export const hangingListStdioFixture = new URL(
'./fixtures/hanging-list-stdio-server.mjs',
import.meta.url,
).pathname;
export const crashAfterConnectFixture = new URL(
'./fixtures/crash-after-connect-stdio-server.mjs',
import.meta.url,
).pathname;
export const stderrThenExitFixture = new URL(
'./fixtures/stderr-then-exit-stdio-server.mjs',
import.meta.url,
).pathname;
export const fixturesDir = fileURLToPath(new URL('./fixtures/', import.meta.url));
export const stdioFixture = fileURLToPath(new URL('./fixtures/mock-stdio-server.mjs', import.meta.url));
export const cwdStdioFixture = fileURLToPath(
new URL('./fixtures/cwd-stdio-server.mjs', import.meta.url),
);
export const slowStdioFixture = fileURLToPath(
new URL('./fixtures/slow-stdio-server.mjs', import.meta.url),
);
export const slowToolStdioFixture = fileURLToPath(
new URL('./fixtures/slow-tool-stdio-server.mjs', import.meta.url),
);
export const hangingListStdioFixture = fileURLToPath(
new URL('./fixtures/hanging-list-stdio-server.mjs', import.meta.url),
);
export const crashAfterConnectFixture = fileURLToPath(
new URL('./fixtures/crash-after-connect-stdio-server.mjs', import.meta.url),
);
export const stderrThenExitFixture = fileURLToPath(
new URL('./fixtures/stderr-then-exit-stdio-server.mjs', import.meta.url),
);

export function createMemoryMcpOAuthStore(): McpOAuthStore {
const data = new Map<string, unknown>();
Expand Down
25 changes: 21 additions & 4 deletions packages/agent-core/src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export interface SocksProxyConfig {
// normalizes brackets away — so including both covers every path.
const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1', '[::1]'] as const;

// Spawned child processes get the loopback list WITHOUT the bracketed
// `[::1]`: Python's httpx (the HTTP client behind many MCP servers, e.g.
// fastmcp-based ones) crashes on it with `Invalid port: ':1]'` while building
// its proxy mounts, which kills every Python stdio MCP server at startup
// whenever a proxy is configured. The bracketed form only exists for undici
// 7.x's NO_PROXY parser (our own in-process dispatcher); for a child, losing
// the bypass for literal `http://[::1]` URLs is a far smaller risk than
// breaking every Python child outright.
const CHILD_LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1'] as const;

const SOCKS_SCHEMES = new Set(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']);

/** Lowercase URL scheme (without the trailing colon), or undefined if absent. */
Expand Down Expand Up @@ -128,8 +138,15 @@ export function isProxyConfigured(env: Env = process.env): boolean {
* honors it as an exact-string match, so appending loopback would silently
* defeat the user's explicit opt-out and route all non-loopback traffic
* through the proxy.
*
* `loopbackHosts` defaults to {@link LOOPBACK_NO_PROXY}; child-process env
* builders pass `CHILD_LOOPBACK_NO_PROXY` instead (no bracketed `[::1]`,
* which crashes Python httpx children).
*/
export function resolveNoProxy(env: Env = process.env): string {
export function resolveNoProxy(
env: Env = process.env,
loopbackHosts: readonly string[] = LOOPBACK_NO_PROXY,
): string {
// Prefer the first non-blank casing; an empty `no_proxy=''` must not mask a
// populated `NO_PROXY` (`??` would, since `''` is not nullish).
const raw = [env['no_proxy'], env['NO_PROXY']].find((value) => (value?.trim() ?? '').length > 0) ?? '';
Expand All @@ -138,7 +155,7 @@ export function resolveNoProxy(env: Env = process.env): string {
.map((host) => host.trim())
.filter((host) => host.length > 0);
if (hosts.includes('*')) return '*';
for (const loopback of LOOPBACK_NO_PROXY) {
for (const loopback of loopbackHosts) {
if (!hosts.includes(loopback)) hosts.push(loopback);
}
return hosts.join(',');
Expand Down Expand Up @@ -335,7 +352,7 @@ export function installGlobalProxyDispatcher(
*/
export function proxyEnvForChild(env: Env = process.env): Record<string, string> {
if (!hasHttpProxy(env)) return {};
const noProxy = resolveNoProxy(env);
const noProxy = resolveNoProxy(env, CHILD_LOOPBACK_NO_PROXY);
const result: Record<string, string> = {
NODE_USE_ENV_PROXY: '1',
NO_PROXY: noProxy,
Expand Down Expand Up @@ -372,7 +389,7 @@ export function reconcileChildNoProxy(
(value) => (value?.trim() ?? '').length > 0,
);
if (override === undefined) return;
const noProxy = resolveNoProxy({ no_proxy: override, NO_PROXY: override });
const noProxy = resolveNoProxy({ no_proxy: override, NO_PROXY: override }, CHILD_LOOPBACK_NO_PROXY);
childEnv['NO_PROXY'] = noProxy;
childEnv['no_proxy'] = noProxy;
}
2 changes: 1 addition & 1 deletion packages/agent-core/test/mcp/client-stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ 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');
});

Expand Down
46 changes: 32 additions & 14 deletions packages/agent-core/test/utils/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ describe('resolveNoProxy', () => {
it('falls through to NO_PROXY when no_proxy is set but blank', () => {
expect(resolveNoProxy({ no_proxy: '', NO_PROXY: 'corp' })).toBe('corp,localhost,127.0.0.1,::1,[::1]');
});

it('honors a custom loopback list (child form drops the bracketed [::1])', () => {
// Child processes get the list without `[::1]`: Python's httpx crashes on
// the bracketed entry with `Invalid port: ':1]'`.
expect(resolveNoProxy({}, ['localhost', '127.0.0.1', '::1'])).toBe('localhost,127.0.0.1,::1');
expect(resolveNoProxy({ NO_PROXY: 'corp' }, ['localhost', '127.0.0.1', '::1'])).toBe(
'corp,localhost,127.0.0.1,::1',
);
});
});

describe('resolveSocksProxy', () => {
Expand Down Expand Up @@ -343,20 +352,29 @@ describe('proxyEnvForChild', () => {
// the resolved value or the protection/proxying is silently defeated.
expect(proxyEnvForChild({ HTTP_PROXY: 'http://p:3128', NO_PROXY: 'corp' })).toEqual({
NODE_USE_ENV_PROXY: '1',
NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]',
no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]',
NO_PROXY: 'corp,localhost,127.0.0.1,::1',
no_proxy: 'corp,localhost,127.0.0.1,::1',
HTTP_PROXY: 'http://p:3128',
http_proxy: 'http://p:3128',
});
});

it('omits the bracketed [::1] from the child NO_PROXY (Python httpx crashes on it)', () => {
// The bracketed form is only needed by undici 7.x's in-process dispatcher;
// a child must get the bare loopback list, while a user-supplied `[::1]`
// in the parent's own NO_PROXY is preserved verbatim.
const env = proxyEnvForChild({ HTTP_PROXY: 'http://p:3128', NO_PROXY: 'corp,[::1]' });
expect(env['NO_PROXY']).toBe('corp,[::1],localhost,127.0.0.1,::1');
expect(env['no_proxy']).toBe('corp,[::1],localhost,127.0.0.1,::1');
});

it('synthesizes scheme-specific proxies from an http-scheme ALL_PROXY for the child', () => {
// Node's --use-env-proxy reads HTTP_PROXY/HTTPS_PROXY, not ALL_PROXY, so an
// ALL_PROXY-only parent must hand the child the scheme-specific vars.
expect(proxyEnvForChild({ ALL_PROXY: 'http://proxy:8080' })).toEqual({
NODE_USE_ENV_PROXY: '1',
NO_PROXY: 'localhost,127.0.0.1,::1,[::1]',
no_proxy: 'localhost,127.0.0.1,::1,[::1]',
NO_PROXY: 'localhost,127.0.0.1,::1',
no_proxy: 'localhost,127.0.0.1,::1',
HTTP_PROXY: 'http://proxy:8080',
http_proxy: 'http://proxy:8080',
HTTPS_PROXY: 'http://proxy:8080',
Expand Down Expand Up @@ -385,32 +403,32 @@ describe('reconcileChildNoProxy', () => {
// would shadow the server config's uppercase override (undici reads
// lowercase first); the override must also keep the loopback bypass.
const childEnv: Record<string, string> = {
NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]',
no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]',
NO_PROXY: 'corp,localhost,127.0.0.1,::1',
no_proxy: 'corp,localhost,127.0.0.1,::1',
};
reconcileChildNoProxy(childEnv, { NO_PROXY: 'server.local' });
expect(childEnv['NO_PROXY']).toBe('server.local,localhost,127.0.0.1,::1,[::1]');
expect(childEnv['no_proxy']).toBe('server.local,localhost,127.0.0.1,::1,[::1]');
expect(childEnv['NO_PROXY']).toBe('server.local,localhost,127.0.0.1,::1');
expect(childEnv['no_proxy']).toBe('server.local,localhost,127.0.0.1,::1');
});

it('prefers the first non-blank casing (lowercase) and keeps loopback', () => {
const childEnv: Record<string, string> = { NO_PROXY: 'aug', no_proxy: 'aug' };
reconcileChildNoProxy(childEnv, { no_proxy: 'lower', NO_PROXY: 'upper' });
expect(childEnv['NO_PROXY']).toBe('lower,localhost,127.0.0.1,::1,[::1]');
expect(childEnv['no_proxy']).toBe('lower,localhost,127.0.0.1,::1,[::1]');
expect(childEnv['NO_PROXY']).toBe('lower,localhost,127.0.0.1,::1');
expect(childEnv['no_proxy']).toBe('lower,localhost,127.0.0.1,::1');
});

it('does not let a blank lowercase no_proxy mask a populated NO_PROXY', () => {
const childEnv: Record<string, string> = { NO_PROXY: 'aug', no_proxy: 'aug' };
reconcileChildNoProxy(childEnv, { no_proxy: '', NO_PROXY: 'real.corp' });
expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]');
expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]');
expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1');
expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1');
});

it('passes the "*" wildcard override through verbatim', () => {
const childEnv: Record<string, string> = {
NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]',
no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]',
NO_PROXY: 'corp,localhost,127.0.0.1,::1',
no_proxy: 'corp,localhost,127.0.0.1,::1',
};
reconcileChildNoProxy(childEnv, { NO_PROXY: '*' });
expect(childEnv['NO_PROXY']).toBe('*');
Expand Down