import { mkdirSync, mkdtempSync, realpathSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { describe, expect, it } from 'vitest'; import { Error2 } from '#/errors'; import { mergeStdioEnv, StdioMcpClient, type StdioMcpClientOptions } from '#/mcpCore/client-stdio'; import type { McpServerStdioConfig } from '#/mcpCore/config-schema'; import { HostProcessService } from '#/os/backends/node-local/hostProcessService'; import { FakeRuntime } from '#/runtime/fakeRuntime'; import { crashAfterConnectFixture, cwdStdioFixture, stderrThenExitFixture, stdioFixture, } from './stubs'; function createClient( config: McpServerStdioConfig, options: Partial = {}, ): StdioMcpClient { const runtime = Object.assign( new FakeRuntime( { workspaceId: 'workspace', runtimeId: 'local', generation: 'test' }, { capabilities: ['process'] }, ), { process: new HostProcessService() }, ); return new StdioMcpClient(config, { runtimeResolver: { _serviceBrand: undefined, inspect: () => runtime, acquire: () => ({ runtime, track: (resource) => resource, dispose: () => {}, }), }, workspaceId: 'workspace', runtimeId: 'local', defaultCwd: process.cwd(), ...options, }); } function isPostCloseTransportError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return ( message.includes('Not connected') || message.includes('Connection closed') || message.includes('transport is not running') ); } describe('StdioMcpClient', () => { it('rejects unsupported executor at construction time', () => { expect( () => createClient({ transport: 'stdio', command: 'true', executor: 'kaos', }), ).toThrow( expect.objectContaining({ name: 'Error2', code: 'not_implemented' }) as unknown as Error, ); let thrown: unknown; try { const client = createClient({ transport: 'stdio', command: 'true', executor: 'kaos' }); void client; } catch (error) { thrown = error; } expect(thrown).toBeInstanceOf(Error2); }); it('uses defaultCwd when config.cwd is omitted', async () => { const cwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-default-cwd-')); const client = createClient( { transport: 'stdio', command: process.execPath, args: [cwdStdioFixture], }, { defaultCwd: cwd }, ); try { await client.connect(); const result = await client.callTool('get_cwd', {}); const text = (result.content[0] as { type: 'text'; text: string }).text; expect(realpathSync(text)).toBe(realpathSync(cwd)); } finally { await client.close(); await rm(cwd, { recursive: true, force: true }); } }, 15000); it('prefers explicit config.cwd over defaultCwd', async () => { const defaultCwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-default-cwd-')); const configuredCwd = join(defaultCwd, 'configured'); mkdirSync(configuredCwd); const client = createClient( { transport: 'stdio', command: process.execPath, args: [cwdStdioFixture], cwd: configuredCwd, }, { defaultCwd }, ); try { await client.connect(); const result = await client.callTool('get_cwd', {}); const text = (result.content[0] as { type: 'text'; text: string }).text; expect(realpathSync(text)).toBe(realpathSync(configuredCwd)); } finally { await client.close(); await rm(defaultCwd, { recursive: true, force: true }); await rm(configuredCwd, { recursive: true, force: true }); } }, 15000); it('resolves relative config.cwd from defaultCwd', async () => { const defaultCwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-relative-cwd-')); const configuredCwd = join(defaultCwd, 'tools', 'mcp'); mkdirSync(configuredCwd, { recursive: true }); const client = createClient( { transport: 'stdio', command: process.execPath, args: [cwdStdioFixture], cwd: 'tools/mcp', }, { defaultCwd }, ); try { await client.connect(); const result = await client.callTool('get_cwd', {}); const text = (result.content[0] as { type: 'text'; text: string }).text; expect(realpathSync(text)).toBe(realpathSync(configuredCwd)); } finally { await client.close(); await rm(defaultCwd, { recursive: true, force: true }); } }, 15000); it('allows explicit config.cwd outside defaultCwd', async () => { const defaultCwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-default-cwd-')); const outsideCwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-outside-cwd-')); const client = createClient( { transport: 'stdio', command: process.execPath, args: [cwdStdioFixture], cwd: outsideCwd, }, { defaultCwd }, ); try { await client.connect(); const result = await client.callTool('get_cwd', {}); const text = (result.content[0] as { type: 'text'; text: string }).text; expect(realpathSync(text)).toBe(realpathSync(outsideCwd)); } finally { await client.close(); await rm(defaultCwd, { recursive: true, force: true }); await rm(outsideCwd, { recursive: true, force: true }); } }, 15000); it('connects, lists tools, and round-trips a text result', async () => { const client = createClient({ transport: 'stdio', command: process.execPath, args: [stdioFixture], }); try { await client.connect(); const tools = await client.listTools(); expect(tools.map((t) => t.name).toSorted()).toEqual([ 'boom', 'echo', 'read_env', 'whoami', ]); const echo = tools.find((t) => t.name === 'echo'); expect(echo?.description).toBe('Echoes input text'); expect(echo?.inputSchema).toMatchObject({ type: 'object' }); const result = await client.callTool('echo', { text: 'hello mcp' }); expect(result.isError).toBe(false); expect(result.content).toEqual([{ type: 'text', text: 'hello mcp' }]); } finally { await client.close(); } }, 15000); it('propagates server-reported isError', async () => { const client = createClient({ transport: 'stdio', command: process.execPath, args: [stdioFixture], }); try { await client.connect(); const result = await client.callTool('boom', {}); expect(result.isError).toBe(true); expect(result.content[0]).toEqual({ type: 'text', text: 'boom!' }); } finally { await client.close(); } }, 15000); it('forwards configured env to the spawned server', async () => { const client = createClient({ transport: 'stdio', command: process.execPath, args: [stdioFixture], env: { KIMI_TEST_ENV: 'forwarded-value' }, }); try { await client.connect(); const result = await client.callTool('read_env', { name: 'KIMI_TEST_ENV' }); expect(result.content).toEqual([{ type: 'text', text: 'forwarded-value' }]); } finally { await client.close(); } }, 15000); it('inherits parent process env so PATH/HOME survive; config.env overrides on conflict', async () => { const parentOnly = `KIMI_TEST_PARENT_${Date.now()}_${Math.random().toString(36).slice(2)}`; const shared = `KIMI_TEST_SHARED_${Date.now()}_${Math.random().toString(36).slice(2)}`; process.env[parentOnly] = 'from-parent'; process.env[shared] = 'from-parent'; const client = createClient({ transport: 'stdio', command: process.execPath, args: [stdioFixture], env: { [shared]: 'from-config' }, }); try { await client.connect(); const inherited = await client.callTool('read_env', { name: parentOnly }); expect(inherited.content).toEqual([{ type: 'text', text: 'from-parent' }]); const overridden = await client.callTool('read_env', { name: shared }); expect(overridden.content).toEqual([{ type: 'text', text: 'from-config' }]); } finally { delete process.env[parentOnly]; delete process.env[shared]; await client.close(); } }, 15000); it('captures recent stderr into a snapshot the manager can attach to errors', async () => { const banner = `kimi-test-stderr-${Date.now()}`; const client = createClient({ transport: 'stdio', command: process.execPath, args: [stderrThenExitFixture], env: { KIMI_TEST_MCP_STDERR: banner }, }); try { await expect(client.connect()).rejects.toThrow(); expect(client.stderrSnapshot()).toContain(banner); } finally { await client.close(); } }, 15000); it('keeps the stderr buffer bounded so noisy servers cannot exhaust memory', async () => { const client = createClient({ transport: 'stdio', command: process.execPath, args: [stdioFixture], }); try { await client.connect(); expect(StdioMcpClient.stderrBufferCapacity).toBeLessThanOrEqual(16 * 1024); expect(StdioMcpClient.stderrBufferCapacity).toBeGreaterThanOrEqual(1024); } finally { await client.close(); } }, 15000); it('notifies an unexpected-close listener when the child exits after connect', async () => { const banner = `kimi-test-crash-${Date.now()}`; const client = createClient({ transport: 'stdio', command: process.execPath, args: [crashAfterConnectFixture], env: { KIMI_TEST_MCP_EXIT_AFTER_MS: '50', KIMI_TEST_MCP_STDERR: banner }, }); const closes: Array<{ stderr?: string; error?: string }> = []; client.onUnexpectedClose((reason) => { closes.push({ stderr: reason.stderr, error: reason.error?.message }); }); try { await client.connect(); for (let i = 0; i < 100; i++) { if (closes.length > 0) break; await new Promise((r) => setTimeout(r, 25)); } expect(closes).toHaveLength(1); expect(closes[0]?.stderr ?? '').toContain(banner); } finally { await client.close(); } }, 15000); it('buffers an early close and replays it on listener registration', async () => { const banner = `kimi-test-early-${Date.now()}`; const client = createClient({ transport: 'stdio', command: process.execPath, args: [crashAfterConnectFixture], env: { KIMI_TEST_MCP_STDERR: banner, KIMI_TEST_MCP_EXIT_CODE: '0' }, }); try { await client.connect(); const reply = await client.callTool('exit_after_reply', {}); expect(reply.isError).toBe(false); const exitDeadline = Date.now() + 5000; while (Date.now() < exitDeadline && !client.stderrSnapshot().includes(banner)) { await new Promise((r) => setTimeout(r, 5)); } expect(client.stderrSnapshot()).toContain(banner); const drainDeadline = Date.now() + 5000; let transportConfirmedDead = false; while (Date.now() < drainDeadline) { try { await client.callTool('echo', { text: 'probe' }); } catch (error) { if (isPostCloseTransportError(error)) { transportConfirmedDead = true; break; } } await new Promise((r) => setTimeout(r, 10)); } expect(transportConfirmedDead).toBe(true); let received: { stderr?: string } | undefined; let syncedOnRegister = false; client.onUnexpectedClose((reason) => { syncedOnRegister = true; received = { stderr: reason.stderr }; }); expect(syncedOnRegister).toBe(true); expect(received).toBeDefined(); } finally { await client.close(); } }, 15000); it('does not fire unexpected-close when the caller closes the client itself', async () => { const client = createClient({ transport: 'stdio', command: process.execPath, args: [stdioFixture], }); const closes: number[] = []; client.onUnexpectedClose(() => closes.push(Date.now())); await client.connect(); await client.close(); await new Promise((r) => setTimeout(r, 100)); expect(closes).toEqual([]); }, 15000); }); describe('mergeStdioEnv', () => { it('enables NODE_USE_ENV_PROXY for a proxy set only in the server config.env', () => { 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['PATH']).toBe('/usr/bin'); }); 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(); expect(merged['PATH']).toBe('/usr/bin'); }); it('lets config.env override the parent env', () => { const merged = mergeStdioEnv({ FOO: 'override' }, { FOO: 'parent', PATH: '/x' }); expect(merged['FOO']).toBe('override'); }); it('does not depend on a filesystem cwd fixture for env merging', async () => { const dir = mkdtempSync(join(tmpdir(), 'kimi-mcp-env-')); await rm(dir, { recursive: true, force: true }); expect(mergeStdioEnv(undefined, { PATH: dir })['PATH']).toBe(dir); }); });