From 980702bc11bb91e8a47d38934f9fe0a4009bff70 Mon Sep 17 00:00:00 2001 From: kevingatera <22301610+kevingatera@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:29:05 -0400 Subject: [PATCH 1/8] feat(mcp): restrict servers to specific models via models field MCP servers were loaded for every session regardless of model, so a model-specific MCP (e.g. the z.ai vision server) could not be attached to just one model without leaking its tools to others. Add an optional models field to the MCP server config (exact alias key or trailing-* prefix wildcard) and filter the merged server set by the session's model alias at create/resume time. Servers without the field keep loading everywhere. --- packages/agent-core/src/config/schema.ts | 6 ++ packages/agent-core/src/mcp/session-config.ts | 41 +++++++++++ packages/agent-core/src/rpc/core-impl.ts | 14 +++- .../test/mcp/session-config-merge.test.ts | 73 ++++++++++++++++++- 4 files changed, 131 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 0e4b7bcaa7..10ec84bccd 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -272,6 +272,12 @@ 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. "zai-coding-plan/glm-5.2"); a trailing "*" is a prefix + // wildcard (e.g. "zai-coding-plan/*"). When set, the server is only loaded + // for sessions whose model matches — used to attach model-specific MCPs + // (like the z.ai vision server) without exposing them to every model. + 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..54a97faccb 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 ("zai-coding-plan/glm-5.2") or + * a trailing-"*" prefix wildcard ("zai-coding-plan/*"). 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..7d0844f223 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -46,6 +46,7 @@ import { resolveMcpToolTimeoutMs, resolveSessionMcpConfig, mergeCallerMcpServers, + filterMcpServersByModel, type BeginAuthorizationResult, type SessionMcpConfig, } from '../mcp'; @@ -389,7 +390,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 +550,16 @@ export class KimiCore implements PromisableMethods { homeDir: this.homeDir, }); const withCallerMcp = mergeCallerMcpServers(baseMcpConfig, input.mcpServers); + // Resume carries no model override in its payload; use the configured + // default to decide which model-scoped MCPs load. + const modelAlias = 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; 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..456768d420 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 glmOnly: McpServerConfig = { + transport: 'stdio', + command: 'vision', + models: ['zai-coding-plan/glm-5.2'], + }; + const zaiPrefix: McpServerConfig = { + transport: 'stdio', + command: 'vision', + models: ['zai-coding-plan/*'], + }; + + it('keeps unscoped servers for any model (backward compatible)', () => { + const cfg: SessionMcpConfig = { servers: { fs: unscoped } }; + expect(filterMcpServersByModel(cfg, 'managed:kimi-code/kimi-for-coding')).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: glmOnly } }; + expect(filterMcpServersByModel(cfg, 'zai-coding-plan/glm-5.2')?.servers).toHaveProperty( + 'vision', + ); + // A different model (kimi-for-coding) must NOT get the GLM-only vision MCP. + expect( + filterMcpServersByModel(cfg, 'managed:kimi-code/kimi-for-coding'), + ).toBeUndefined(); + }); + + it('honors a trailing-* prefix wildcard', () => { + const cfg: SessionMcpConfig = { servers: { vision: zaiPrefix } }; + expect(filterMcpServersByModel(cfg, 'zai-coding-plan/glm-5.2')?.servers).toHaveProperty( + 'vision', + ); + expect(filterMcpServersByModel(cfg, 'zai-coding-plan/glm-5.1')?.servers).toHaveProperty( + 'vision', + ); + expect( + filterMcpServersByModel(cfg, 'managed:kimi-code/kimi-for-coding'), + ).toBeUndefined(); + }); + + it('excludes model-scoped servers when the model is unknown', () => { + const cfg: SessionMcpConfig = { servers: { vision: glmOnly } }; + // Undecided model → do not leak a GLM-only 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: glmOnly } }; + // GLM session keeps both; kimi session keeps only the unscoped one. + expect(filterMcpServersByModel(cfg, 'zai-coding-plan/glm-5.2')?.servers).toEqual({ + fs: unscoped, + vision: glmOnly, + }); + expect(filterMcpServersByModel(cfg, 'managed:kimi-code/kimi-for-coding')?.servers).toEqual({ + fs: unscoped, + }); + }); + + it('mcpServerAppliesToModel: unscoped server always applies', () => { + expect(mcpServerAppliesToModel(unscoped, undefined)).toBe(true); + }); +}); From 11e2ab9b04bf9f30fe7106f50d578b6bc07c4950 Mon Sep 17 00:00:00 2001 From: kevingatera <22301610+kevingatera@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:59:53 -0400 Subject: [PATCH 2/8] chore: add changeset for MCP model scoping --- .changeset/mcp-model-scoping.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mcp-model-scoping.md diff --git a/.changeset/mcp-model-scoping.md b/.changeset/mcp-model-scoping.md new file mode 100644 index 0000000000..e3289131c4 --- /dev/null +++ b/.changeset/mcp-model-scoping.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a models field to MCP server configuration to expose a server only to specific models. Set models on an MCP server entry to scope it. From 151047700e46d7cf9143ad98f56ed19ef3671a58 Mon Sep 17 00:00:00 2001 From: kevingatera <22301610+kevingatera@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:18:51 -0400 Subject: [PATCH 3/8] chore(agent-core): use neutral placeholders in MCP model scoping comments and tests --- packages/agent-core/src/config/schema.ts | 7 ++-- packages/agent-core/src/mcp/session-config.ts | 4 +- .../test/mcp/session-config-merge.test.ts | 40 +++++++++---------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 10ec84bccd..a6ec4aae3f 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -273,10 +273,9 @@ const McpServerCommonFields = { 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. "zai-coding-plan/glm-5.2"); a trailing "*" is a prefix - // wildcard (e.g. "zai-coding-plan/*"). When set, the server is only loaded - // for sessions whose model matches — used to attach model-specific MCPs - // (like the z.ai vision server) without exposing them to every model. + // 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; diff --git a/packages/agent-core/src/mcp/session-config.ts b/packages/agent-core/src/mcp/session-config.ts index 54a97faccb..3795b4d446 100644 --- a/packages/agent-core/src/mcp/session-config.ts +++ b/packages/agent-core/src/mcp/session-config.ts @@ -40,8 +40,8 @@ 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 ("zai-coding-plan/glm-5.2") or - * a trailing-"*" prefix wildcard ("zai-coding-plan/*"). When the model is + * 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. */ 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 456768d420..d500357cf5 100644 --- a/packages/agent-core/test/mcp/session-config-merge.test.ts +++ b/packages/agent-core/test/mcp/session-config-merge.test.ts @@ -64,61 +64,61 @@ describe('mergeCallerMcpServers', () => { describe('filterMcpServersByModel', () => { const unscoped: McpServerConfig = { transport: 'stdio', command: 'fs' }; - const glmOnly: McpServerConfig = { + const visionOnly: McpServerConfig = { transport: 'stdio', command: 'vision', - models: ['zai-coding-plan/glm-5.2'], + models: ['example-provider/vision-large'], }; - const zaiPrefix: McpServerConfig = { + const providerPrefix: McpServerConfig = { transport: 'stdio', command: 'vision', - models: ['zai-coding-plan/*'], + models: ['example-provider/*'], }; it('keeps unscoped servers for any model (backward compatible)', () => { const cfg: SessionMcpConfig = { servers: { fs: unscoped } }; - expect(filterMcpServersByModel(cfg, 'managed:kimi-code/kimi-for-coding')).toEqual(cfg); + 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: glmOnly } }; - expect(filterMcpServersByModel(cfg, 'zai-coding-plan/glm-5.2')?.servers).toHaveProperty( + const cfg: SessionMcpConfig = { servers: { vision: visionOnly } }; + expect(filterMcpServersByModel(cfg, 'example-provider/vision-large')?.servers).toHaveProperty( 'vision', ); - // A different model (kimi-for-coding) must NOT get the GLM-only vision MCP. + // A different model must NOT get the vision-only MCP. expect( - filterMcpServersByModel(cfg, 'managed:kimi-code/kimi-for-coding'), + filterMcpServersByModel(cfg, 'other-provider/chat-model'), ).toBeUndefined(); }); it('honors a trailing-* prefix wildcard', () => { - const cfg: SessionMcpConfig = { servers: { vision: zaiPrefix } }; - expect(filterMcpServersByModel(cfg, 'zai-coding-plan/glm-5.2')?.servers).toHaveProperty( + const cfg: SessionMcpConfig = { servers: { vision: providerPrefix } }; + expect(filterMcpServersByModel(cfg, 'example-provider/vision-large')?.servers).toHaveProperty( 'vision', ); - expect(filterMcpServersByModel(cfg, 'zai-coding-plan/glm-5.1')?.servers).toHaveProperty( + expect(filterMcpServersByModel(cfg, 'example-provider/text-small')?.servers).toHaveProperty( 'vision', ); expect( - filterMcpServersByModel(cfg, 'managed:kimi-code/kimi-for-coding'), + filterMcpServersByModel(cfg, 'other-provider/chat-model'), ).toBeUndefined(); }); it('excludes model-scoped servers when the model is unknown', () => { - const cfg: SessionMcpConfig = { servers: { vision: glmOnly } }; - // Undecided model → do not leak a GLM-only MCP into the session. + 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: glmOnly } }; - // GLM session keeps both; kimi session keeps only the unscoped one. - expect(filterMcpServersByModel(cfg, 'zai-coding-plan/glm-5.2')?.servers).toEqual({ + 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: glmOnly, + vision: visionOnly, }); - expect(filterMcpServersByModel(cfg, 'managed:kimi-code/kimi-for-coding')?.servers).toEqual({ + expect(filterMcpServersByModel(cfg, 'other-provider/chat-model')?.servers).toEqual({ fs: unscoped, }); }); From 866de23ab5c0228700440e7e90397775f0ea8911 Mon Sep 17 00:00:00 2001 From: kevingatera <22301610+kevingatera@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:18:51 -0400 Subject: [PATCH 4/8] test(agent-core): cover session MCP model scoping wiring at session creation --- .../test/harness/model-alias-session.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) 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..66a5927d80 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,52 @@ 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('loads configured permission rules into created and resumed sessions', async () => { await writeFile( configPath, From 865b01c65993207908d91ccb702ccaa5986ac45f Mon Sep 17 00:00:00 2001 From: kevingatera <22301610+kevingatera@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:18:52 -0400 Subject: [PATCH 5/8] chore: clarify MCP model scoping changeset entry --- .changeset/mcp-model-scoping.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mcp-model-scoping.md b/.changeset/mcp-model-scoping.md index e3289131c4..4061193245 100644 --- a/.changeset/mcp-model-scoping.md +++ b/.changeset/mcp-model-scoping.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add a models field to MCP server configuration to expose a server only to specific models. Set models on an MCP server entry to scope it. +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. From 9b7544737e82d20ac359a4d566defb821d46fa01 Mon Sep 17 00:00:00 2001 From: kevingatera <22301610+kevingatera@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:18:52 -0400 Subject: [PATCH 6/8] docs: document models field for MCP server entries --- docs/en/customization/mcp.md | 1 + docs/zh/customization/mcp.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 2768220074..d3cfce54b7 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 | 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..c0f2d2f3cb 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*` 前缀通配。仅当会话模型匹配时才加载,模型未知时不加载;在会话启动时评估 | 连接超时和单次工具调用超时的默认值都不必逐个 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)。 From d78993901243fcbd67a2aed8aa5611bab15640e7 Mon Sep 17 00:00:00 2001 From: kevingatera <22301610+kevingatera@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:09:52 -0400 Subject: [PATCH 7/8] fix(agent-core): scope resumed-session MCP servers to the persisted model Resume filtered model-scoped MCP servers against config.defaultModel, but a session may have been created with an explicit --model or switched models mid-session; that alias is persisted on the wire and replayed by session.resume() after the filter ran. Recover the last config.update model alias from the main agent's wire.jsonl before filtering, falling back to the configured default when no alias is persisted. --- packages/agent-core/src/rpc/core-impl.ts | 33 +++++++++++-- .../test/harness/model-alias-session.test.ts | 48 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 7d0844f223..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, @@ -550,9 +553,13 @@ export class KimiCore implements PromisableMethods { homeDir: this.homeDir, }); const withCallerMcp = mergeCallerMcpServers(baseMcpConfig, input.mcpServers); - // Resume carries no model override in its payload; use the configured - // default to decide which model-scoped MCPs load. - const modelAlias = config.defaultModel; + // 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(); @@ -626,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 66a5927d80..6496bf3450 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -351,6 +351,54 @@ max_context_size = 200000 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, From da283e475487125cfcd2fb6fc588c43d3d1f4539 Mon Sep 17 00:00:00 2001 From: kevingatera <22301610+kevingatera@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:09:52 -0400 Subject: [PATCH 8/8] docs: note MCP models field is honored by the v1 engine only --- docs/en/customization/mcp.md | 2 +- docs/zh/customization/mcp.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index d3cfce54b7..aa7824c5e7 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -56,7 +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 | +| `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 c0f2d2f3cb..37d7efdf58 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -56,7 +56,7 @@ MCP server 配置写在 `mcp.json` 中,分两层: | `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时,取值范围为 `1` 到 `2147483647` 毫秒 | | `enabledTools` | `string[]` | 全部 | 工具白名单 | | `disabledTools` | `string[]` | 全部 | 工具黑名单 | -| `models` | `string[]` | 全部 | 将该 server 限定到指定模型别名;条目为精确别名或 `prefix*` 前缀通配。仅当会话模型匹配时才加载,模型未知时不加载;在会话启动时评估 | +| `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)。