Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-mcp-web-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix MCP server working directories when sessions are hosted by the web server.
10 changes: 9 additions & 1 deletion packages/agent-core/src/mcp/client-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core/src/mcp/connection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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, {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,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);
Expand Down
49 changes: 49 additions & 0 deletions packages/agent-core/test/mcp/client-stdio.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');

Expand All @@ -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',
Expand Down
36 changes: 36 additions & 0 deletions packages/agent-core/test/mcp/connection-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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[] = [];
Expand Down
21 changes: 21 additions & 0 deletions packages/agent-core/test/mcp/fixtures/cwd-stdio-server.mjs
Original file line number Diff line number Diff line change
@@ -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