From ee62859ce34e05cadf44cd5224f5fa07a7cb8f4a Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Fri, 31 Jul 2026 23:18:55 +0800 Subject: [PATCH 1/2] test(agent-core-v2): resolve mcpCore fixture paths with fileURLToPath new URL(...).pathname yields /C:/... on Windows, which produced bogus C:\C:\... fixture paths and made every mcpCore test spawning a fixture fail with 'Cannot find module'. Match the v1 tests and convert via fileURLToPath so the suites run on Windows. --- packages/agent-core-v2/test/mcpCore/stubs.ts | 41 ++++++++++---------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/agent-core-v2/test/mcpCore/stubs.ts b/packages/agent-core-v2/test/mcpCore/stubs.ts index 2e11f25c74..da3caee483 100644 --- a/packages/agent-core-v2/test/mcpCore/stubs.ts +++ b/packages/agent-core-v2/test/mcpCore/stubs.ts @@ -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'; @@ -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(); From 8de4b45511a99b6a5e8b01fdb0b2f712e249202e Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Fri, 31 Jul 2026 23:19:13 +0800 Subject: [PATCH 2/2] fix(agent-core): drop the bracketed [::1] from child-process NO_PROXY When an HTTP(S) proxy is configured, proxyEnvForChild/reconcileChildNoProxy injected NO_PROXY=localhost,127.0.0.1,::1,[::1] into every spawned stdio MCP server. Python's httpx (the HTTP client behind fastmcp and many other MCP servers) crashes while parsing the bracketed [::1] entry (InvalidURL: Invalid port: ':1]'), so every Python-based MCP server failed at startup with 'Connection closed' for all proxy users. The bracketed form only exists for undici 7.x's own NO_PROXY parser, which mis-parses a bare ::1 as host ':' port '1'. That concern applies to our in-process dispatcher, not to child env: children get the unbracketed loopback list (localhost,127.0.0.1,::1), which both httpx and undici handle. Losing the proxy bypass for literal http://[::1] URLs in Node children is a far smaller risk than breaking every Python child outright. resolveNoProxy keeps its previous default (bracketed list) for the in-process dispatcher; only the child-env builders pass the reduced list. Refs #1931 --- .changeset/mcp-child-noproxy-ipv6.md | 5 ++ .../agent-core-v2/src/_base/utils/proxy.ts | 21 +++++++-- .../test/_base/utils/proxy.test.ts | 8 ++-- .../test/mcpCore/client-stdio.test.ts | 2 +- packages/agent-core/src/utils/proxy.ts | 25 ++++++++-- .../agent-core/test/mcp/client-stdio.test.ts | 2 +- packages/agent-core/test/utils/proxy.test.ts | 46 +++++++++++++------ 7 files changed, 81 insertions(+), 28 deletions(-) create mode 100644 .changeset/mcp-child-noproxy-ipv6.md diff --git a/.changeset/mcp-child-noproxy-ipv6.md b/.changeset/mcp-child-noproxy-ipv6.md new file mode 100644 index 0000000000..64d86aa127 --- /dev/null +++ b/.changeset/mcp-child-noproxy-ipv6.md @@ -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. diff --git a/packages/agent-core-v2/src/_base/utils/proxy.ts b/packages/agent-core-v2/src/_base/utils/proxy.ts index 7a4e06806d..60397a1f05 100644 --- a/packages/agent-core-v2/src/_base/utils/proxy.ts +++ b/packages/agent-core-v2/src/_base/utils/proxy.ts @@ -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 +// `[::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 { @@ -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(','); @@ -232,7 +245,7 @@ export function installGlobalProxyDispatcher( export function proxyEnvForChild(env: Env): Record { if (!hasHttpProxy(env)) return {}; - const noProxy = resolveNoProxy(env); + const noProxy = resolveNoProxy(env, CHILD_LOOPBACK_NO_PROXY); const result: Record = { NODE_USE_ENV_PROXY: '1', NO_PROXY: noProxy, @@ -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; } diff --git a/packages/agent-core-v2/test/_base/utils/proxy.test.ts b/packages/agent-core-v2/test/_base/utils/proxy.test.ts index 404e47dd6d..ed5db94e9b 100644 --- a/packages/agent-core-v2/test/_base/utils/proxy.test.ts +++ b/packages/agent-core-v2/test/_base/utils/proxy.test.ts @@ -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', }); @@ -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'); }); }); 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..63358b2c03 100644 --- a/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts @@ -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'); }); diff --git a/packages/agent-core/src/utils/proxy.ts b/packages/agent-core/src/utils/proxy.ts index 2f475ef1f9..339c363ac5 100644 --- a/packages/agent-core/src/utils/proxy.ts +++ b/packages/agent-core/src/utils/proxy.ts @@ -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. */ @@ -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) ?? ''; @@ -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(','); @@ -335,7 +352,7 @@ export function installGlobalProxyDispatcher( */ export function proxyEnvForChild(env: Env = process.env): Record { if (!hasHttpProxy(env)) return {}; - const noProxy = resolveNoProxy(env); + const noProxy = resolveNoProxy(env, CHILD_LOOPBACK_NO_PROXY); const result: Record = { NODE_USE_ENV_PROXY: '1', NO_PROXY: noProxy, @@ -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; } diff --git a/packages/agent-core/test/mcp/client-stdio.test.ts b/packages/agent-core/test/mcp/client-stdio.test.ts index ff2c4296dd..beb2d8de3c 100644 --- a/packages/agent-core/test/mcp/client-stdio.test.ts +++ b/packages/agent-core/test/mcp/client-stdio.test.ts @@ -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'); }); diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts index 91b8bce927..dc0eb0da54 100644 --- a/packages/agent-core/test/utils/proxy.test.ts +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -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', () => { @@ -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', @@ -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 = { - 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 = { 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 = { 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 = { - 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('*');