Skip to content
Open
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/warn-mcp-server-shadowing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Warn when project MCP configuration overrides a user-level MCP server with the same name.
2 changes: 2 additions & 0 deletions docs/en/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ MCP tools are named in the format `mcp__<server>__<tool>`, for example `mcp__git

Calls that do not match any permission rule trigger an approval request. Selecting "Approve for this session" in the approval dialog automatically allows subsequent calls of the same kind within the current session.

Server-scoped rules trust the configured server name. If a project-level MCP config defines the same server name as your user-level config, the project definition overrides the user definition and matching rules such as `mcp__github__*` may apply to the project-defined server.

You can also pre-configure permanent rules in `[[permission.rules]]` in `config.toml`:

```toml
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ MCP 工具按 `mcp__<server>__<tool>` 格式命名,例如 `mcp__github__create

未命中权限规则的调用会触发审批请求;在审批弹窗中选择"Approve for this session"后,本次会话内的后续同类调用自动放行。

按 server 匹配的规则信任配置中的 server 名称。如果项目级 MCP 配置定义了与用户级配置相同的 server 名称,项目定义会覆盖用户定义,`mcp__github__*` 等匹配规则可能会应用到项目定义的 server。

也可以在 `config.toml` 的 `[[permission.rules]]` 中预置永久规则:

```toml
Expand Down
40 changes: 39 additions & 1 deletion packages/agent-core/src/mcp/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { McpServerConfigSchema, type McpServerConfig } from '#/config/schema';
import { ErrorCodes, KimiError } from '#/errors';
import { z } from 'zod';

import { sanitizeMcpNamePart } from './tool-naming';

const McpJsonFileSchema = z.object({
mcpServers: z.record(z.string(), McpServerConfigSchema).default({}),
});
Expand Down Expand Up @@ -36,6 +38,11 @@ export interface LoadMcpServersInput {
readonly homeDir?: string;
}

export interface LoadMcpServersResult {
readonly servers: Record<string, McpServerConfig>;
readonly warnings: readonly string[];
}

/**
* Load MCP server declarations from the user-global `~/.kimi-code/mcp.json`,
* the project-root `<project root>/.mcp.json`, and the project-local
Expand All @@ -50,13 +57,44 @@ export interface LoadMcpServersInput {
export async function loadMcpServers(
input: LoadMcpServersInput,
): Promise<Record<string, McpServerConfig>> {
return (await loadMcpServersWithDiagnostics(input)).servers;
}

export async function loadMcpServersWithDiagnostics(
input: LoadMcpServersInput,
): Promise<LoadMcpServersResult> {
const paths = await resolveMcpJsonPaths({ cwd: input.cwd, homeDir: input.homeDir });
const [user, projectRoot, project] = await Promise.all([
readMcpJson(paths.user),
readMcpJson(paths.projectRoot, { stdioCwdBase: dirname(paths.projectRoot) }),
readMcpJson(paths.project),
]);
return { ...user, ...projectRoot, ...project };
return {
servers: { ...user, ...projectRoot, ...project },
warnings: [
...mcpShadowWarnings(user, projectRoot, 'project-root'),
...mcpShadowWarnings(user, project, 'project-local'),
],
};
}

function mcpShadowWarnings(
userServers: Record<string, McpServerConfig>,
projectServers: Record<string, McpServerConfig>,
source: 'project-root' | 'project-local',
): string[] {
return Object.keys(projectServers)
.filter((name) => Object.hasOwn(userServers, name))
.toSorted()
.map(
(name) => {
const permissionPattern = `mcp__${sanitizeMcpNamePart(name)}__*`;
return (
`Project MCP server "${name}" defined in ${source} config overrides a user-level MCP server. ` +
`Server-scoped permission rules such as "${permissionPattern}" may apply to the project-defined server.`
);
},
);
}

async function findProjectRoot(cwd: string): Promise<string> {
Expand Down
9 changes: 6 additions & 3 deletions packages/agent-core/src/mcp/session-config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { McpServerConfig } from '#/config/schema';

import { loadMcpServers } from './config-loader';
import { loadMcpServersWithDiagnostics } from './config-loader';

export interface SessionMcpConfig {
readonly servers: Record<string, McpServerConfig>;
readonly warnings?: readonly string[];
}

export interface ResolveSessionMcpConfigInput {
Expand All @@ -14,12 +15,13 @@ export interface ResolveSessionMcpConfigInput {
export async function resolveSessionMcpConfig(
input: ResolveSessionMcpConfigInput,
): Promise<SessionMcpConfig | undefined> {
const servers = await loadMcpServers({
const result = await loadMcpServersWithDiagnostics({
cwd: input.cwd,
homeDir: input.homeDir,
});
const servers = result.servers;
if (Object.keys(servers).length === 0) return undefined;
return { servers };
return { servers, warnings: result.warnings };
}

export function mergeCallerMcpServers(
Expand All @@ -34,5 +36,6 @@ export function mergeCallerMcpServers(
...base?.servers,
...callerServers,
},
warnings: base?.warnings,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve MCP diagnostics when plugin servers are enabled

This carries warnings through caller-server merging, but every session then calls mergePluginMcpConfig; when any plugin MCP server is enabled it returns a fresh { servers } object without warnings (packages/agent-core/src/rpc/core-impl.ts:997-1005). In that context—a user/project MCP name collision plus at least one enabled plugin MCP server—the new diagnostics are dropped before Session.loadMcpServers() reads them, so the promised shadowing warning is never logged.

Useful? React with 👍 / 👎.

};
}
3 changes: 3 additions & 0 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,9 @@ export class Session {
private async loadMcpServers(): Promise<void> {
const servers = this.options.mcpConfig?.servers;
if (servers === undefined || Object.keys(servers).length === 0) return;
for (const warning of this.options.mcpConfig?.warnings ?? []) {
this.log.warn('mcp config warning', { warning });
}
await this.mcp.connectAll(servers);
const entries = this.mcp.list().filter((entry) => entry.status !== 'disabled');
const totalCount = entries.length;
Expand Down
34 changes: 33 additions & 1 deletion packages/agent-core/test/mcp/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { join } from 'pathe';
import { afterEach, describe, expect, it } from 'vitest';

import { ErrorCodes, KimiError } from '../../src/errors';
import { loadMcpServers, resolveMcpJsonPaths } from '../../src/mcp/config-loader';
import {
loadMcpServers,
loadMcpServersWithDiagnostics,
resolveMcpJsonPaths,
} from '../../src/mcp/config-loader';

const tempDirs: string[] = [];

Expand Down Expand Up @@ -92,6 +96,34 @@ describe('loadMcpServers', () => {
});
});

it('warns when project-local mcp.json shadows a user-global server name', async () => {
const home = makeTempDir();
const cwd = makeTempDir();

await writeJson(join(home, 'mcp.json'), {
mcpServers: {
github: { transport: 'http', url: 'https://mcp.example.com/github' },
},
});
await writeJson(join(cwd, '.kimi-code', 'mcp.json'), {
mcpServers: {
github: { transport: 'stdio', command: 'node', args: ['project-server.mjs'] },
},
});

const result = await loadMcpServersWithDiagnostics({ cwd, homeDir: home });

expect(result.servers['github']).toEqual({
transport: 'stdio',
command: 'node',
args: ['project-server.mjs'],
});
expect(result.warnings).toEqual([
expect.stringContaining('Project MCP server "github" defined in project-local config overrides a user-level MCP server'),
]);
expect(result.warnings[0]).toContain('mcp__github__*');
});

it('loads root .mcp.json from the repo root and lets project-local .kimi-code/mcp.json override it', async () => {
const home = makeTempDir();
const repoRoot = makeTempDir();
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/test/mcp/session-config-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('mergeCallerMcpServers', () => {
shared: stdio('disk-version'),
diskOnly: stdio('disk-only'),
},
warnings: ['Project MCP server "shared" overrides a user-level MCP server.'],
};
const callerServers = {
shared: stdio('caller-version'),
Expand All @@ -53,6 +54,7 @@ describe('mergeCallerMcpServers', () => {
diskOnly: stdio('disk-only'),
callerOnly: http('https://caller.example.com'),
},
warnings: ['Project MCP server "shared" overrides a user-level MCP server.'],
});
});
});