Skip to content
5 changes: 5 additions & 0 deletions .changeset/mcp-model-scoping.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/en/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
1 change: 1 addition & 0 deletions docs/zh/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
41 changes: 41 additions & 0 deletions packages/agent-core/src/mcp/session-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, McpServerConfig> = {};
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 };
}
41 changes: 39 additions & 2 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -46,6 +49,7 @@ import {
resolveMcpToolTimeoutMs,
resolveSessionMcpConfig,
mergeCallerMcpServers,
filterMcpServersByModel,
type BeginAuthorizationResult,
type SessionMcpConfig,
} from '../mcp';
Expand Down Expand Up @@ -389,7 +393,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
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).
Expand Down Expand Up @@ -546,10 +553,20 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
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;
Expand Down Expand Up @@ -616,6 +633,26 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
);
}

/**
* 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<string | undefined> {
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<ResumeSessionResult> {
const summary = await this.sessionStore.get(input.sessionId);
const active = this.sessions.get(summary.id);
Expand Down
94 changes: 94 additions & 0 deletions packages/agent-core/test/harness/model-alias-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createTestRpc>>,
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,
Expand Down
73 changes: 72 additions & 1 deletion packages/agent-core/test/mcp/session-config-merge.test.ts
Original file line number Diff line number Diff line change
@@ -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 => ({
Expand Down Expand Up @@ -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);
});
});