diff --git a/.changeset/mcp-model-scoping.md b/.changeset/mcp-model-scoping.md new file mode 100644 index 0000000000..4061193245 --- /dev/null +++ b/.changeset/mcp-model-scoping.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +MCP server entries in mcp.json / config now accept an optional `models` list of exact model aliases or `prefix*` wildcards, and a server with `models` is loaded only for sessions whose model matches. Scoping is evaluated at session start — switching models mid-session does not reload MCP servers. diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 2768220074..aa7824c5e7 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -56,6 +56,7 @@ Optional fields: | `toolTimeoutMs` | `number` | All | Timeout from `1` to `2147483647` milliseconds for a single tool call | | `enabledTools` | `string[]` | All | Tool allowlist | | `disabledTools` | `string[]` | All | Tool blocklist | +| `models` | `string[]` | All | Restrict this server to specific model aliases; entries are exact aliases or `prefix*` wildcards. The server loads only for sessions whose model matches, and is excluded when the model is unknown; evaluated at session start. Currently honored by the v1 engine only | You do not have to set the connection timeout or the single tool-call timeout per server: `[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms` in `config.toml` or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` environment variables change the global defaults. Precedence is: per-server field > environment variable > `config.toml` > built-in default. See [Configuration files](../configuration/config-files.md#mcp). diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index 93cb8031c9..37d7efdf58 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -56,6 +56,7 @@ MCP server 配置写在 `mcp.json` 中,分两层: | `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时,取值范围为 `1` 到 `2147483647` 毫秒 | | `enabledTools` | `string[]` | 全部 | 工具白名单 | | `disabledTools` | `string[]` | 全部 | 工具黑名单 | +| `models` | `string[]` | 全部 | 将该 server 限定到指定模型别名;条目为精确别名或 `prefix*` 前缀通配。仅当会话模型匹配时才加载,模型未知时不加载;在会话启动时评估。目前仅 v1 引擎支持该字段 | 连接超时和单次工具调用超时的默认值都不必逐个 server 设置:`config.toml` 的 `[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms` 或环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` 可以调整全局默认值,优先级为 server 字段 > 环境变量 > `config.toml` > 内置默认。详见 [配置文件](../configuration/config-files.md#mcp)。 diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 0e4b7bcaa7..a6ec4aae3f 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -272,6 +272,11 @@ const McpServerCommonFields = { toolTimeoutMs: McpTimeoutMsSchema.optional(), enabledTools: z.array(z.string()).optional(), disabledTools: z.array(z.string()).optional(), + // Restrict this server to specific model aliases. Entries are model alias + // keys (e.g. "example-provider/vision-large"); a trailing "*" is a prefix + // wildcard (e.g. "example-provider/*"). When set, the server is only loaded + // for sessions whose model matches. + models: z.array(z.string()).optional(), } as const; export const McpServerStdioConfigSchema = z.object({ diff --git a/packages/agent-core/src/mcp/session-config.ts b/packages/agent-core/src/mcp/session-config.ts index d3fea129b5..3795b4d446 100644 --- a/packages/agent-core/src/mcp/session-config.ts +++ b/packages/agent-core/src/mcp/session-config.ts @@ -36,3 +36,44 @@ export function mergeCallerMcpServers( }, }; } + +/** + * Does `server` apply to `modelAlias`? A server with no `models` restriction + * applies everywhere. A restricted server applies only when `modelAlias` + * matches one of its entries — exact key match ("example-provider/vision-large") + * or a trailing-"*" prefix wildcard ("example-provider/*"). When the model is + * unknown, restricted servers are excluded so a model-specific MCP does not + * leak into a session whose model is undecided. + */ +export function mcpServerAppliesToModel( + server: McpServerConfig, + modelAlias: string | undefined, +): boolean { + const models = server.models; + if (models === undefined || models.length === 0) return true; + if (modelAlias === undefined) return false; + return models.some((pattern) => + pattern.endsWith('*') + ? modelAlias.startsWith(pattern.slice(0, -1)) + : pattern === modelAlias, + ); +} + +/** + * Drop MCP servers whose `models` restriction excludes `modelAlias`. Servers + * without a restriction are always kept (backward compatible). + */ +export function filterMcpServersByModel( + config: SessionMcpConfig | undefined, + modelAlias: string | undefined, +): SessionMcpConfig | undefined { + if (config === undefined) return undefined; + const filtered: Record = {}; + for (const [name, server] of Object.entries(config.servers)) { + if (mcpServerAppliesToModel(server, modelAlias)) { + filtered[name] = server; + } + } + if (Object.keys(filtered).length === 0) return undefined; + return { servers: filtered }; +} diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 319b55551f..3832ede9ba 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; +import { join } from 'pathe'; + import { ErrorCodes, KimiError } from '#/errors'; import { getRootLogger, log } from '#/logging/logger'; import { PluginManager } from '#/plugin'; @@ -12,6 +14,7 @@ import type { PromisableMethods } from '#/utils/types'; import { getCoreVersion } from '#/version'; import { resolveThinkingEffort } from '../agent/config/thinking'; import { Agent } from '../agent'; +import { FileSystemAgentRecordPersistence } from '../agent/records'; import { limitAgentReplayByTurns } from '../agent/replay/turns'; import { applyPrintModeConfigDefaults, @@ -46,6 +49,7 @@ import { resolveMcpToolTimeoutMs, resolveSessionMcpConfig, mergeCallerMcpServers, + filterMcpServersByModel, type BeginAuthorizationResult, type SessionMcpConfig, } from '../mcp'; @@ -389,7 +393,10 @@ export class KimiCore implements PromisableMethods { await this.pluginsReady; const pluginSessionStarts = this.plugins.enabledSessionStarts(); const pluginCommands = await this.plugins.enabledCommands(); - const mcpConfig = this.mergePluginMcpConfig(withCallerMcp); + const mcpConfig = filterMcpServersByModel( + this.mergePluginMcpConfig(withCallerMcp), + modelAlias, + ); // Session ctor attaches its own log sink. If anything in the setup-after- // ctor block throws, `session.close()` releases the sink (and mcp). @@ -546,10 +553,20 @@ export class KimiCore implements PromisableMethods { homeDir: this.homeDir, }); const withCallerMcp = mergeCallerMcpServers(baseMcpConfig, input.mcpServers); + // Resume carries no model override in its payload; recover the model the + // main agent persisted on the wire (explicit `--model` at create or a + // mid-session switch) so model-scoped MCPs load for the model the session + // will actually replay — falling back to the configured default when the + // wire records no alias (e.g. migrated or never-configured sessions). + const modelAlias = + (await this.readPersistedMainModelAlias(summary.sessionDir)) ?? config.defaultModel; await this.pluginsReady; const pluginSessionStarts = this.plugins.enabledSessionStarts(); const pluginCommands = await this.plugins.enabledCommands(); - const mcpConfig = this.mergePluginMcpConfig(withCallerMcp); + const mcpConfig = filterMcpServersByModel( + this.mergePluginMcpConfig(withCallerMcp), + modelAlias, + ); const runtime = await this.resolveRuntime(config); const parentKaos = parentKaosForRead; const persistenceKaos = overrides.persistenceKaos ?? parentKaos; @@ -616,6 +633,26 @@ export class KimiCore implements PromisableMethods { ); } + /** + * Last `config.update` model alias the main agent persisted on its wire — + * the alias `session.resume()` will replay. Wires with no alias record + * (migrated sessions, never-configured sessions) yield undefined. + */ + private async readPersistedMainModelAlias( + sessionDir: string, + ): Promise { + const persistence = new FileSystemAgentRecordPersistence( + join(sessionDir, 'agents', 'main', 'wire.jsonl'), + ); + let modelAlias: string | undefined; + for await (const record of persistence.read()) { + if (record.type === 'config.update' && record.modelAlias !== undefined) { + modelAlias = record.modelAlias; + } + } + return modelAlias; + } + async reloadSession(input: ReloadSessionPayload): Promise { const summary = await this.sessionStore.get(input.sessionId); const active = this.sessions.get(summary.id); diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 35d914a38b..6496bf3450 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -305,6 +305,100 @@ max_context_size = 200000 expect(await rpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(''); }); + it('scopes caller MCP servers to the session model at creation', async () => { + await writeFile( + configPath, + `${CONFIG} + +[providers.other] +type = "openai" +api_key = "sk-other" +base_url = "https://other.example/v1" + +[models."other/model"] +provider = "other" +model = "other-model" +max_context_size = 200000 +`, + ); + const rpc = await createTestRpc(); + + // Bogus commands never connect, but every configured server still shows + // up in listMcpServers — so the list proves which servers the session + // was wired with. + const mcpServers = { + fs: { transport: 'stdio' as const, command: 'missing-mcp-fs' }, + codingOnly: { + transport: 'stdio' as const, + command: 'missing-mcp-coding', + models: ['kimi-code/kimi-for-coding'], + }, + otherOnly: { + transport: 'stdio' as const, + command: 'missing-mcp-other', + models: ['other/*'], + }, + }; + const names = async (sessionId: string) => + (await rpc.listMcpServers({ sessionId })).map((server) => server.name).sort(); + + // No explicit model → config.defaultModel drives the filter. + const byDefault = await rpc.createSession({ workDir, mcpServers }); + expect(await names(byDefault.id)).toEqual(['codingOnly', 'fs']); + + // Explicit model → options.model drives the filter, including wildcards. + const byOption = await rpc.createSession({ workDir, model: 'other/model', mcpServers }); + expect(await names(byOption.id)).toEqual(['fs', 'otherOnly']); + }); + + it('scopes caller MCP servers to the persisted session model on resume', async () => { + await writeFile( + configPath, + `${CONFIG} + +[providers.other] +type = "openai" +api_key = "sk-other" +base_url = "https://other.example/v1" + +[models."other/model"] +provider = "other" +model = "other-model" +max_context_size = 200000 +`, + ); + const mcpServers = { + fs: { transport: 'stdio' as const, command: 'missing-mcp-fs' }, + codingOnly: { + transport: 'stdio' as const, + command: 'missing-mcp-coding', + models: ['kimi-code/kimi-for-coding'], + }, + otherOnly: { + transport: 'stdio' as const, + command: 'missing-mcp-other', + models: ['other/*'], + }, + }; + const names = async ( + client: Awaited>, + sessionId: string, + ) => (await client.listMcpServers({ sessionId })).map((server) => server.name).sort(); + + // The session's model differs from the global default and is persisted on + // the wire as a `config.update` record. + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir, model: 'other/model', mcpServers }); + expect(await names(rpc, created.id)).toEqual(['fs', 'otherOnly']); + await rpc.closeSession({ sessionId: created.id }); + + // A fresh core would filter against config.defaultModel alone; resume must + // recover the persisted model first so the scoped set matches it instead. + const freshRpc = await createTestRpc(); + await freshRpc.resumeSession({ sessionId: created.id, mcpServers }); + expect(await names(freshRpc, created.id)).toEqual(['fs', 'otherOnly']); + }); + it('loads configured permission rules into created and resumed sessions', async () => { await writeFile( configPath, 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..d500357cf5 100644 --- a/packages/agent-core/test/mcp/session-config-merge.test.ts +++ b/packages/agent-core/test/mcp/session-config-merge.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { mergeCallerMcpServers, type SessionMcpConfig } from '../../src/mcp/session-config'; +import { + mergeCallerMcpServers, + filterMcpServersByModel, + mcpServerAppliesToModel, + type SessionMcpConfig, +} from '../../src/mcp/session-config'; import type { McpServerConfig } from '../../src/config/schema'; const stdio = (command: string): McpServerConfig => ({ @@ -56,3 +61,69 @@ describe('mergeCallerMcpServers', () => { }); }); }); + +describe('filterMcpServersByModel', () => { + const unscoped: McpServerConfig = { transport: 'stdio', command: 'fs' }; + const visionOnly: McpServerConfig = { + transport: 'stdio', + command: 'vision', + models: ['example-provider/vision-large'], + }; + const providerPrefix: McpServerConfig = { + transport: 'stdio', + command: 'vision', + models: ['example-provider/*'], + }; + + it('keeps unscoped servers for any model (backward compatible)', () => { + const cfg: SessionMcpConfig = { servers: { fs: unscoped } }; + expect(filterMcpServersByModel(cfg, 'other-provider/chat-model')).toEqual(cfg); + expect(filterMcpServersByModel(cfg, undefined)).toEqual(cfg); + }); + + it('keeps a model-scoped server only when the alias matches exactly', () => { + const cfg: SessionMcpConfig = { servers: { vision: visionOnly } }; + expect(filterMcpServersByModel(cfg, 'example-provider/vision-large')?.servers).toHaveProperty( + 'vision', + ); + // A different model must NOT get the vision-only MCP. + expect( + filterMcpServersByModel(cfg, 'other-provider/chat-model'), + ).toBeUndefined(); + }); + + it('honors a trailing-* prefix wildcard', () => { + const cfg: SessionMcpConfig = { servers: { vision: providerPrefix } }; + expect(filterMcpServersByModel(cfg, 'example-provider/vision-large')?.servers).toHaveProperty( + 'vision', + ); + expect(filterMcpServersByModel(cfg, 'example-provider/text-small')?.servers).toHaveProperty( + 'vision', + ); + expect( + filterMcpServersByModel(cfg, 'other-provider/chat-model'), + ).toBeUndefined(); + }); + + it('excludes model-scoped servers when the model is unknown', () => { + const cfg: SessionMcpConfig = { servers: { vision: visionOnly } }; + // Undecided model → do not leak a scoped MCP into the session. + expect(filterMcpServersByModel(cfg, undefined)).toBeUndefined(); + }); + + it('filters a mixed set down to the matching subset', () => { + const cfg: SessionMcpConfig = { servers: { fs: unscoped, vision: visionOnly } }; + // A matching session keeps both; a non-matching session keeps only the unscoped one. + expect(filterMcpServersByModel(cfg, 'example-provider/vision-large')?.servers).toEqual({ + fs: unscoped, + vision: visionOnly, + }); + expect(filterMcpServersByModel(cfg, 'other-provider/chat-model')?.servers).toEqual({ + fs: unscoped, + }); + }); + + it('mcpServerAppliesToModel: unscoped server always applies', () => { + expect(mcpServerAppliesToModel(unscoped, undefined)).toBe(true); + }); +});