diff --git a/.changeset/warn-mcp-server-shadowing.md b/.changeset/warn-mcp-server-shadowing.md new file mode 100644 index 0000000000..ea62bf085c --- /dev/null +++ b/.changeset/warn-mcp-server-shadowing.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Warn when project MCP configuration overrides a user-level MCP server with the same name. diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index dfad6acd60..6112bc527d 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -71,6 +71,8 @@ MCP tools are named in the format `mcp____`, 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 diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index 96a4e66dd9..db0eab8b89 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -71,6 +71,8 @@ MCP 工具按 `mcp____` 格式命名,例如 `mcp__github__create 未命中权限规则的调用会触发审批请求;在审批弹窗中选择"Approve for this session"后,本次会话内的后续同类调用自动放行。 +按 server 匹配的规则信任配置中的 server 名称。如果项目级 MCP 配置定义了与用户级配置相同的 server 名称,项目定义会覆盖用户定义,`mcp__github__*` 等匹配规则可能会应用到项目定义的 server。 + 也可以在 `config.toml` 的 `[[permission.rules]]` 中预置永久规则: ```toml diff --git a/packages/agent-core/src/mcp/config-loader.ts b/packages/agent-core/src/mcp/config-loader.ts index 26cd8a7eef..136834ea1b 100644 --- a/packages/agent-core/src/mcp/config-loader.ts +++ b/packages/agent-core/src/mcp/config-loader.ts @@ -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({}), }); @@ -36,6 +38,11 @@ export interface LoadMcpServersInput { readonly homeDir?: string; } +export interface LoadMcpServersResult { + readonly servers: Record; + readonly warnings: readonly string[]; +} + /** * Load MCP server declarations from the user-global `~/.kimi-code/mcp.json`, * the project-root `/.mcp.json`, and the project-local @@ -50,13 +57,44 @@ export interface LoadMcpServersInput { export async function loadMcpServers( input: LoadMcpServersInput, ): Promise> { + return (await loadMcpServersWithDiagnostics(input)).servers; +} + +export async function loadMcpServersWithDiagnostics( + input: LoadMcpServersInput, +): Promise { 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, + projectServers: Record, + 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 { diff --git a/packages/agent-core/src/mcp/session-config.ts b/packages/agent-core/src/mcp/session-config.ts index d3fea129b5..52a5537ebf 100644 --- a/packages/agent-core/src/mcp/session-config.ts +++ b/packages/agent-core/src/mcp/session-config.ts @@ -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; + readonly warnings?: readonly string[]; } export interface ResolveSessionMcpConfigInput { @@ -14,12 +15,13 @@ export interface ResolveSessionMcpConfigInput { export async function resolveSessionMcpConfig( input: ResolveSessionMcpConfigInput, ): Promise { - 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( @@ -34,5 +36,6 @@ export function mergeCallerMcpServers( ...base?.servers, ...callerServers, }, + warnings: base?.warnings, }; } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 75f1a302ed..ef3cbb0ef9 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -750,6 +750,9 @@ export class Session { private async loadMcpServers(): Promise { 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; diff --git a/packages/agent-core/test/mcp/config-loader.test.ts b/packages/agent-core/test/mcp/config-loader.test.ts index f281c05518..f093adbf76 100644 --- a/packages/agent-core/test/mcp/config-loader.test.ts +++ b/packages/agent-core/test/mcp/config-loader.test.ts @@ -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[] = []; @@ -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(); diff --git a/packages/agent-core/test/mcp/session-config-merge.test.ts b/packages/agent-core/test/mcp/session-config-merge.test.ts index f9cb0c2b9b..881a152495 100644 --- a/packages/agent-core/test/mcp/session-config-merge.test.ts +++ b/packages/agent-core/test/mcp/session-config-merge.test.ts @@ -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'), @@ -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.'], }); }); });