diff --git a/.changeset/fix-mcp-web-cwd.md b/.changeset/fix-mcp-web-cwd.md new file mode 100644 index 000000000..4b7f114d4 --- /dev/null +++ b/.changeset/fix-mcp-web-cwd.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix MCP server working directories when sessions are hosted by the web server. diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index adffda0ca..9810bc0e9 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -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 diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 7d3c9c1f3..f5c011fae 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -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, { diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index bc6642151..1fd9e9e6b 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -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); diff --git a/packages/agent-core/test/mcp/client-stdio.test.ts b/packages/agent-core/test/mcp/client-stdio.test.ts index 2e18c5406..fc0cd8b35 100644 --- a/packages/agent-core/test/mcp/client-stdio.test.ts +++ b/packages/agent-core/test/mcp/client-stdio.test.ts @@ -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', diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index a6930b948..77078cf6a 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -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[] = []; diff --git a/packages/agent-core/test/mcp/fixtures/cwd-stdio-server.mjs b/packages/agent-core/test/mcp/fixtures/cwd-stdio-server.mjs new file mode 100644 index 000000000..acbbb67ae --- /dev/null +++ b/packages/agent-core/test/mcp/fixtures/cwd-stdio-server.mjs @@ -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());