diff --git a/.changeset/v2-caller-mcp-servers.md b/.changeset/v2-caller-mcp-servers.md new file mode 100644 index 0000000000..6843254018 --- /dev/null +++ b/.changeset/v2-caller-mcp-servers.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Support caller-supplied MCP server configs on session create in the v2 engine (experimental), merged over the file config and under plugin servers. diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index a62bc45844..ff6178c39c 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -17,6 +17,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ISessionScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; +import type { McpServerConfig } from '#/agent/mcp/config-schema'; import type { Hooks } from '#/hooks'; export interface CreateSessionOptions { @@ -29,6 +30,13 @@ export interface CreateSessionOptions { readonly workDir: string; /** Extra workspace roots for this session; relative paths resolve against workDir. */ readonly additionalDirs?: readonly string[]; + /** + * Caller-supplied MCP servers for this session (v1's + * `CreateSessionPayload.mcpServers`): merged over the file config and under + * plugin servers when the session's MCP connections are established. Only + * `create` carries them; resumes and forks connect from the file config alone. + */ + readonly mcpServers?: Readonly>; } export interface ForkSessionOptions { diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index dcc9cd14c3..063e10f749 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -200,7 +200,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this.sessions.set(opts.sessionId, handle); await handle.accessor.get(ISessionMetadata).ready; void handle.accessor.get(ISessionSkillCatalog).ready; - await handle.accessor.get(IAgentLifecycleService).ensureMcpReady(); + // First `ensureMcpReady` call for the session — it starts the initial MCP + // load, so the caller-supplied servers must ride on it (later calls, e.g. + // from agent creation, only await the in-flight load). + await handle.accessor.get(IAgentLifecycleService).ensureMcpReady(opts.mcpServers); handle.accessor.get(ISessionExternalHooksService); return handle; } diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index 7b01995680..fbb7535e44 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -33,6 +33,7 @@ import type { IAgentScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; import type { TokenUsage } from '#/app/llmProtocol/usage'; import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { McpServerConfig } from '#/agent/mcp/config-schema'; import type { BindAgentInput } from '#/agent/profile/profile'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import type { Turn } from '#/agent/loop/loop'; @@ -152,11 +153,15 @@ export interface IAgentLifecycleService { create(opts?: CreateAgentOptions): Promise; whenReady(agentId: string): Promise; /** - * Resolve the session/plugin MCP config and wait for the initial connection - * attempt to finish. Per-server failures are reflected in MCP status entries - * rather than rejecting this promise. + * Resolve the session MCP config (file config + caller-supplied servers, with + * plugin servers on top) and wait for the initial connection attempt to + * finish. Per-server failures are reflected in MCP status entries rather than + * rejecting this promise. `callerServers` is honored only by the call that + * starts the initial load — `sessionLifecycle.materializeSession` passes the + * session's caller-supplied servers there; later callers (e.g. agent + * creation) just await the in-flight load. */ - ensureMcpReady(): Promise; + ensureMcpReady(callerServers?: Readonly>): Promise; notifyMainCreated(handle: IAgentScopeHandle): void; /** * Fire {@link onDidStopAgentTask} for a mirrored run that has stopped. diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 5cecd3c336..ab15aa7309 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -37,9 +37,10 @@ import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentP import { IAgentMcpService } from '#/agent/mcp/mcp'; import { AgentMcpService } from '#/agent/mcp/mcpService'; import { McpConnectionManager } from '#/agent/mcp/connection-manager'; +import type { McpServerConfig } from '#/agent/mcp/config-schema'; import { McpOAuthService } from '#/agent/mcp/oauth/service'; import { createMcpOAuthStore } from '#/agent/mcp/oauth/store'; -import { resolveSessionMcpConfig } from '#/agent/mcp/session-config'; +import { mergeCallerMcpServers, resolveSessionMcpConfig } from '#/agent/mcp/session-config'; import { IPluginService } from '#/app/plugin/plugin'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; @@ -395,10 +396,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle ]); } - ensureMcpReady(): Promise { + ensureMcpReady(callerServers?: Readonly>): Promise { if (this.mcpInitialLoad !== undefined) return this.mcpInitialLoad; const manager = this.getMcpManager(); - const initialLoad = this.connectMcpServers(manager).catch((error: unknown) => { + const initialLoad = this.connectMcpServers(manager, callerServers).catch((error: unknown) => { this.log.error('mcp initial load failed', { error }); const message = error instanceof Error ? error.message : String(error); this.handles.get('main')?.accessor.get(IEventBus)?.publish({ @@ -486,12 +487,18 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle return manager; } - private async connectMcpServers(manager: McpConnectionManager): Promise { + private async connectMcpServers( + manager: McpConnectionManager, + callerServers?: Readonly>, + ): Promise { const [base, pluginServers] = await Promise.all([ resolveSessionMcpConfig({ cwd: this.workspace.workDir, homeDir: this.bootstrap.homeDir }), this.plugins.enabledMcpServers(), ]); - const servers = { ...base?.servers, ...pluginServers }; + // Precedence mirrors v1's `mergeCallerMcpServers` + `mergePluginMcpConfig` + // (`rpc/core-impl.ts`): file config < caller-supplied < plugin. + const withCaller = mergeCallerMcpServers(base, callerServers); + const servers = { ...withCaller?.servers, ...pluginServers }; if (Object.keys(servers).length === 0) return; await manager.connectAll(servers); this.trackMcpInitialLoad(manager); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 8eb745c793..ec0241861d 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -488,6 +488,16 @@ describe('SessionLifecycleService', () => { expect(h.kind).toBe(LifecycleScope.Session); }); + it('create forwards caller-supplied MCP servers to the session MCP initial load', async () => { + const ensureMcpReady = vi.fn(() => Promise.resolve()); + const svc = build([ + stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), ensureMcpReady }), + ]); + const mcpServers = { docs: { transport: 'http', url: 'https://mcp.example.com' } } as const; + await svc.create({ sessionId: 's1', workDir: '/tmp/proj', mcpServers }); + expect(ensureMcpReady).toHaveBeenCalledWith(mcpServers); + }); + it('create appends the session to the shared session_index.jsonl', async () => { const appended: unknown[] = []; const svc = build([ diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index b244709b5b..8b809ccfb2 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -463,6 +463,35 @@ describe('AgentLifecycleService', () => { expect(settled).toBe(true); }); + it('merges caller-supplied MCP servers into the initial connect (file < caller < plugin)', async () => { + ix.stub(IPluginService, { + ...pluginServiceStub, + enabledMcpServers: async () => ({ + shared: { transport: 'stdio', command: 'plugin-version' }, + }), + } as unknown as IPluginService); + const connectAll = vi + .spyOn(McpConnectionManager.prototype, 'connectAll') + .mockResolvedValue(undefined); + + const svc = ix.get(IAgentLifecycleService); + await svc.ensureMcpReady({ + shared: { transport: 'stdio', command: 'caller-version' }, + callerOnly: { transport: 'http', url: 'https://caller.example.com' }, + }); + + expect(connectAll).toHaveBeenCalledTimes(1); + expect(connectAll).toHaveBeenCalledWith({ + shared: { transport: 'stdio', command: 'plugin-version' }, + callerOnly: { transport: 'http', url: 'https://caller.example.com' }, + }); + + // The initial load is single-flight: later calls only await it and never + // re-merge a different caller payload. + await svc.ensureMcpReady({ ignored: { transport: 'stdio', command: 'ignored' } }); + expect(connectAll).toHaveBeenCalledTimes(1); + }); + it('whenReady waits for an in-flight creation to finish bootstrap', async () => { let releaseRegister!: () => void; registerAgent.mockImplementationOnce(