kimi-code / packages /agent-core-v2 /test /mcpCore /client-stdio.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f0634fb verified
Raw History Blame Contribute Delete
13.5 kB
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<StdioMcpClientOptions> = {},
): 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);
});
});