Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/v2-caller-mcp-servers.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Record<string, McpServerConfig>>;
}

export interface ForkSessionOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent early agent creation from consuming the MCP load

When a caller supplies an explicit sessionId, materializeSession publishes the handle in this.sessions before this call, so a concurrent request can resolve that half-created session and create the main agent; AgentLifecycleService.create() then calls ensureMcpReady() with no caller servers, starts the single-flight load, and this later call only awaits it, dropping the caller-supplied MCP servers. Please hide creating sessions like resumes or start the MCP load with opts.mcpServers before the handle becomes observable.

Useful? React with 👍 / 👎.

handle.accessor.get(ISessionExternalHooksService);
return handle;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,11 +153,15 @@ export interface IAgentLifecycleService {
create(opts?: CreateAgentOptions): Promise<IAgentScopeHandle>;
whenReady(agentId: string): Promise<IAgentScopeHandle | undefined>;
/**
* 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<void>;
ensureMcpReady(callerServers?: Readonly<Record<string, McpServerConfig>>): Promise<void>;
notifyMainCreated(handle: IAgentScopeHandle): void;
/**
* Fire {@link onDidStopAgentTask} for a mirrored run that has stopped.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -395,10 +396,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
]);
}

ensureMcpReady(): Promise<void> {
ensureMcpReady(callerServers?: Readonly<Record<string, McpServerConfig>>): Promise<void> {
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({
Expand Down Expand Up @@ -486,12 +487,18 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
return manager;
}

private async connectMcpServers(manager: McpConnectionManager): Promise<void> {
private async connectMcpServers(
manager: McpConnectionManager,
callerServers?: Readonly<Record<string, McpServerConfig>>,
): Promise<void> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading