fix: use session cwd for stdio MCP servers (#1057)

This commit is contained in:
7Sageer 2026-06-24 13:40:56 +08:00 committed by GitHub
parent a752a5309b
commit ee69e16dc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 123 additions and 2 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix MCP server working directories when sessions are hosted by the web server.

View file

@ -3,6 +3,7 @@ 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 { isAbsolute, resolve } from 'pathe';
import {
buildRequestOptions,
@ -19,6 +20,7 @@ export interface StdioMcpClientOptions {
readonly clientName?: string;
readonly clientVersion?: string;
readonly toolCallTimeoutMs?: number;
readonly defaultCwd?: string;
}
const STDERR_BUFFER_CAPACITY = 4 * 1024;
@ -61,7 +63,7 @@ export class StdioMcpClient implements MCPClient {
command: config.command,
args: config.args,
env: mergeStdioEnv(config.env),
cwd: config.cwd,
cwd: resolveStdioCwd(config.cwd, options.defaultCwd),
stderr: 'pipe',
});
// `stderr: 'pipe'` means we MUST drain the stream — otherwise the child
@ -216,6 +218,12 @@ class BoundedTail {
}
}
function resolveStdioCwd(configCwd: string | undefined, defaultCwd: string | undefined): string | undefined {
if (configCwd === undefined) return defaultCwd;
if (defaultCwd !== undefined && !isAbsolute(configCwd)) return resolve(defaultCwd, configCwd);
return configCwd;
}
// Inherit the parent's env so PATH/HOME/etc. survive — otherwise `npx`/`uvx`
// style stdio servers fail to launch even with a valid config. `config.env`
// overrides on conflict. A node child does not inherit our in-process undici

View file

@ -42,6 +42,7 @@ type RuntimeMcpClient = StdioMcpClient | HttpMcpClient | SseMcpClient;
export interface McpConnectionManagerOptions {
readonly envLookup?: (name: string) => string | undefined;
readonly stdioCwd?: string;
/**
* Optional OAuth orchestrator. When provided, remote servers without a
* static bearer token participate in the OAuth-via-synthetic-tool flow:
@ -331,7 +332,7 @@ export class McpConnectionManager {
private createClient(config: McpServerConfig, name: string): RuntimeMcpClient {
const toolCallTimeoutMs = config.toolTimeoutMs;
if (config.transport === 'stdio') {
return new StdioMcpClient(config, { toolCallTimeoutMs });
return new StdioMcpClient(config, { toolCallTimeoutMs, defaultCwd: this.options.stdioCwd });
}
if (config.transport === 'sse') {
return new SseMcpClient(config, {

View file

@ -202,6 +202,7 @@ export class Session {
this.mcp = new McpConnectionManager({
oauthService: new McpOAuthService({ kimiHomeDir: options.kimiHomeDir }),
log: this.log,
stdioCwd: options.kaos.getcwd(),
});
this.mcp.onStatusChange((entry) => {
this.onMcpServerStatusChange(entry);

View file

@ -1,3 +1,6 @@
import { mkdtempSync, realpathSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'pathe';
import { fileURLToPath } from 'node:url';
@ -8,6 +11,7 @@ import { mergeStdioEnv, StdioMcpClient } from '../../src/mcp/client-stdio';
const here = import.meta.dirname;
const fixture = join(here, 'fixtures', 'mock-stdio-server.mjs');
const cwdFixture = join(here, 'fixtures', 'cwd-stdio-server.mjs');
const stderrThenExitFixture = join(here, 'fixtures', 'stderr-then-exit-stdio-server.mjs');
const crashAfterConnectFixture = join(here, 'fixtures', 'crash-after-connect-stdio-server.mjs');
@ -34,6 +38,51 @@ describe('StdioMcpClient', () => {
expect(thrown).toBeInstanceOf(KimiError);
});
it('uses defaultCwd when config.cwd is omitted', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-default-cwd-'));
const client = new StdioMcpClient(
{
transport: 'stdio',
command: process.execPath,
args: [cwdFixture],
},
{ 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 = mkdtempSync(join(tmpdir(), 'kimi-mcp-configured-cwd-'));
const client = new StdioMcpClient(
{
transport: 'stdio',
command: process.execPath,
args: [cwdFixture],
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('connects, lists tools, and round-trips a text result', async () => {
const client = new StdioMcpClient({
transport: 'stdio',

View file

@ -1,3 +1,4 @@
import { realpathSync } from 'node:fs';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'pathe';
@ -32,6 +33,7 @@ import { createScriptedGenerate } from '../agent/harness';
const here = import.meta.dirname;
const stdioFixture = join(here, 'fixtures', 'mock-stdio-server.mjs');
const cwdStdioFixture = join(here, 'fixtures', 'cwd-stdio-server.mjs');
const slowStdioFixture = join(here, 'fixtures', 'slow-stdio-server.mjs');
const crashAfterConnectFixture = join(here, 'fixtures', 'crash-after-connect-stdio-server.mjs');
const stderrThenExitFixture = join(here, 'fixtures', 'stderr-then-exit-stdio-server.mjs');
@ -793,6 +795,40 @@ describe('Session MCP startup', () => {
}
}, 7000);
it('starts stdio MCP servers in the session cwd when config.cwd is omitted', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'kimi-session-mcp-cwd-'));
const session = new Session({
id: 'test-mcp-cwd',
kaos: testKaos.withCwd(tmp),
homedir: join(tmp, 'session'),
rpc: sessionRpc(),
mcpConfig: {
servers: {
cwd: {
transport: 'stdio',
command: process.execPath,
args: [cwdStdioFixture],
startupTimeoutMs: 2_000,
},
},
},
});
try {
await session.mcp.waitForInitialLoad();
const resolved = session.mcp.resolved('cwd');
if (resolved === undefined) {
throw new Error('MCP server cwd did not connect');
}
const result = await resolved.client.callTool('get_cwd', {});
const text = (result.content[0] as { type: 'text'; text: string }).text;
expect(realpathSync(text)).toBe(realpathSync(tmp));
} finally {
await session.close();
await rm(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
}
}, 7000);
it('waits for initial MCP startup before the first prompt reaches the model', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'kimi-session-mcp-prompt-'));
const events: SessionRpcEvent[] = [];

View file

@ -0,0 +1,21 @@
// Minimal MCP stdio server fixture for cwd assertions.
// Exposes:
// - get_cwd() -> the server process cwd
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const server = new McpServer({ name: 'cwd-stdio', version: '0.0.1' });
server.registerTool(
'get_cwd',
{
description: 'Returns the server process cwd',
inputSchema: {},
},
() => ({
content: [{ type: 'text', text: process.cwd() }],
}),
);
await server.connect(new StdioServerTransport());