mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
fix: use session cwd for stdio MCP servers (#1057)
This commit is contained in:
parent
a752a5309b
commit
ee69e16dc8
7 changed files with 123 additions and 2 deletions
5
.changeset/fix-mcp-web-cwd.md
Normal file
5
.changeset/fix-mcp-web-cwd.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix MCP server working directories when sessions are hosted by the web server.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
21
packages/agent-core/test/mcp/fixtures/cwd-stdio-server.mjs
Normal file
21
packages/agent-core/test/mcp/fixtures/cwd-stdio-server.mjs
Normal 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());
|
||||
Loading…
Add table
Add a link
Reference in a new issue