diff --git a/.changeset/fix-v2-caller-mcp-servers.md b/.changeset/fix-v2-caller-mcp-servers.md new file mode 100644 index 0000000000..ef5652d08f --- /dev/null +++ b/.changeset/fix-v2-caller-mcp-servers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Fix caller-supplied MCP servers being silently dropped when creating a session through the v2 engine (experimental). diff --git a/.changeset/fix-v2-skill-dirs-access.md b/.changeset/fix-v2-skill-dirs-access.md new file mode 100644 index 0000000000..1bd4407be5 --- /dev/null +++ b/.changeset/fix-v2-skill-dirs-access.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kap-server": patch +"@moonshot-ai/kimi-code": patch +--- + +Make file tools able to reach skill directories outside the working directory in the v2 engine (experimental), and honor --skillsDir in v2 print mode and the server's skillDirs option. diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 61f0454277..dd8e6c7a9e 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -36,6 +36,7 @@ import { logSeed, resolveKimiHome, resolveLoggingConfig, + skillCatalogRuntimeOptionsSeed, type DomainEvent, type IAgentScopeHandle, type ISessionScopeHandle, @@ -115,6 +116,9 @@ export async function runV2Print( const { app } = bootstrap({ homeDir, clientVersion: version }, [ ...logSeed(logging), ...hostRequestHeadersSeed(hostHeaders), + // `--skillsDir` (v1 print parity): explicit skill dirs replace default + // user / project discovery for this process. + ...skillCatalogRuntimeOptionsSeed(opts.skillsDirs), ]); const auth = app.accessor.get(IOAuthToolkit); diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index b1135a70fa..8a74961fc0 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -15,8 +15,10 @@ import { IOAuthToolkit, ISessionIndex, ISessionLifecycleService, + ISkillCatalogRuntimeOptions, ITelemetryService, type DomainEvent, + type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; import { runV2Print } from '../../src/cli/v2/run-v2-print'; @@ -112,6 +114,99 @@ function opts(overrides: Record = {}) { } as const; } +function makeFakeHarness() { + // Native event listeners registered on the main agent's IEventBus; the turn + // emits a streaming assistant delta before completing. + const eventListeners = new Set<(event: DomainEvent) => void>(); + + const agentServices = new Map([ + [IAgentProfileService, { setModel: vi.fn(async () => ({ model: 'k2' })), getModel: () => 'k2' }], + [IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }], + [IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }], + [ + IEventBus, + { + subscribe: vi.fn((handler: (event: DomainEvent) => void) => { + eventListeners.add(handler); + return { dispose: () => eventListeners.delete(handler) }; + }), + }, + ], + [ + IAgentPromptService, + { + enqueue: vi.fn(async () => { + // Emit a native assistant delta on the main agent bus, then complete. + for (const listener of [...eventListeners]) { + listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as DomainEvent); + } + return { + launched: Promise.resolve({ + id: 1, + result: Promise.resolve({ type: 'completed' }), + }), + }; + }), + }, + ], + [IAgentTaskService, { list: vi.fn(() => []) }], + [IAgentGoalService, { createGoal: vi.fn(), getGoal: vi.fn() }], + ]); + const agent = fakeScope('main', agentServices); + + const sessionServices = new Map([ + // drain enumerates agents; empty → no background work to wait on. + [IAgentLifecycleService, { list: vi.fn(() => []) }], + ]); + const session = fakeScope('ses_v2', sessionServices); + + const appServices = new Map([ + [ + IConfigService, + { + ready: Promise.resolve(), + get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)), + diagnostics: vi.fn(() => []), + }, + ], + [ + ISessionLifecycleService, + { + create: vi.fn(async () => session), + resume: vi.fn(async () => session), + }, + ], + [ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }], + [ + IBootstrapService, + { + platform: 'linux', + arch: 'x64', + clientVersion: '1.2.3-test', + getEnv: () => undefined, + }, + ], + [IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }], + [IFileSystemStorageService, {}], + [ + ITelemetryService, + (() => { + const svc = { + setAppender: vi.fn(), + setContext: vi.fn(), + track: vi.fn(), + track2: vi.fn(), + shutdown: vi.fn(async () => {}), + withContext: vi.fn(() => svc), + }; + return svc; + })(), + ], + ]); + const app = fakeScope('app', appServices); + return { app, agent, session, agentServices }; +} + describe('runV2Print', () => { beforeEach(() => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); @@ -126,96 +221,7 @@ describe('runV2Print', () => { it('submits a prompt, renders native events, awaits completion, and drains', async () => { const stdout = writer(); const stderr = writer(); - - // Native event listeners registered on the main agent's IEventBus; the turn - // emits a streaming assistant delta before completing. - const eventListeners = new Set<(event: DomainEvent) => void>(); - - const agentServices = new Map([ - [IAgentProfileService, { setModel: vi.fn(async () => ({ model: 'k2' })), getModel: () => 'k2' }], - [IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }], - [IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }], - [ - IEventBus, - { - subscribe: vi.fn((handler: (event: DomainEvent) => void) => { - eventListeners.add(handler); - return { dispose: () => eventListeners.delete(handler) }; - }), - }, - ], - [ - IAgentPromptService, - { - enqueue: vi.fn(async () => { - // Emit a native assistant delta on the main agent bus, then complete. - for (const listener of [...eventListeners]) { - listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as DomainEvent); - } - return { - launched: Promise.resolve({ - id: 1, - result: Promise.resolve({ type: 'completed' }), - }), - }; - }), - }, - ], - [IAgentTaskService, { list: vi.fn(() => []) }], - [IAgentGoalService, { createGoal: vi.fn(), getGoal: vi.fn() }], - ]); - const agent = fakeScope('main', agentServices); - - const sessionServices = new Map([ - // drain enumerates agents; empty → no background work to wait on. - [IAgentLifecycleService, { list: vi.fn(() => []) }], - ]); - const session = fakeScope('ses_v2', sessionServices); - - const appServices = new Map([ - [ - IConfigService, - { - ready: Promise.resolve(), - get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)), - diagnostics: vi.fn(() => []), - }, - ], - [ - ISessionLifecycleService, - { - create: vi.fn(async () => session), - resume: vi.fn(async () => session), - }, - ], - [ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }], - [ - IBootstrapService, - { - platform: 'linux', - arch: 'x64', - clientVersion: '1.2.3-test', - getEnv: () => undefined, - }, - ], - [IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }], - [IFileSystemStorageService, {}], - [ - ITelemetryService, - (() => { - const svc = { - setAppender: vi.fn(), - setContext: vi.fn(), - track: vi.fn(), - track2: vi.fn(), - shutdown: vi.fn(async () => {}), - withContext: vi.fn(() => svc), - }; - return svc; - })(), - ], - ]); - const app = fakeScope('app', appServices); + const { app, agent, agentServices } = makeFakeHarness(); mocks.bootstrap.mockReturnValue({ app }); mocks.ensureMainAgent.mockResolvedValue(agent); @@ -236,4 +242,36 @@ describe('runV2Print', () => { expect(stdout.text()).toContain('hello world'); expect(app.dispose).toHaveBeenCalled(); }); + + it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts({ skillsDirs: ['/skills'] }) as never, '1.2.3-test', { + stdout, + stderr, + }); + + const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; + const seeded = seeds.find(([id]) => id === ISkillCatalogRuntimeOptions); + expect(seeded?.[1]).toMatchObject({ explicitDirs: ['/skills'] }); + }); + + it('leaves the skill runtime options unseeded when --skillsDir is empty', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; + expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false); + }); }); diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts index 7fa9ad1638..c56e4f67fd 100644 --- a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -25,9 +25,11 @@ import { IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { extendWorkspaceWithSkillRoots } from '#/tool/path-access'; import { IAgentMediaToolsRegistrar } from './mediaTools'; import { createVideoUploader, registerMediaTools } from './registerMediaTools'; @@ -47,6 +49,9 @@ export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaT @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ITelemetryService private readonly telemetry: ITelemetryService, + // Optional so unit tests that construct the registrar directly (bypassing + // DI) keep working; always registered in production scopes. + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) { super(); this.refresh(); @@ -65,18 +70,26 @@ export class AgentMediaToolsRegistrar extends Disposable implements IAgentMediaT this.registeredKey = key; this.registration?.dispose(); const workspaceCtx = this.workspaceCtx; + const skillCatalog = this.skillCatalog; + const env = this.env; const model = this.profile.resolveModel(); this.registration = registerMediaTools(this.toolRegistry, { fs: this.fs, env: this.env, // Live view: `workDir` is runtime-mutable (`/cwd`), and the tool keeps - // its WorkspaceConfig across calls, so a snapshot would go stale. + // its WorkspaceConfig across calls, so a snapshot would go stale. Skill + // roots are merged per read for the same reason (the catalog loads + // asynchronously and gains roots on plugin reloads). workspace: { get workspaceDir() { return workspaceCtx.workDir; }, get additionalDirs() { - return workspaceCtx.additionalDirs; + return extendWorkspaceWithSkillRoots( + { workspaceDir: workspaceCtx.workDir, additionalDirs: workspaceCtx.additionalDirs }, + skillCatalog?.catalog.getSkillRoots() ?? [], + env.pathClass, + ).additionalDirs; }, }, capabilities, diff --git a/packages/agent-core-v2/src/app/edit/tools/edit.ts b/packages/agent-core-v2/src/app/edit/tools/edit.ts index eefd64f955..672a9fd8f0 100644 --- a/packages/agent-core-v2/src/app/edit/tools/edit.ts +++ b/packages/agent-core-v2/src/app/edit/tools/edit.ts @@ -20,11 +20,16 @@ import { z } from 'zod'; -import { resolvePathAccessPath, type WorkspaceConfig } from '#/tool/path-access'; +import { + extendWorkspaceWithSkillRoots, + resolvePathAccessPath, + type WorkspaceConfig, +} from '#/tool/path-access'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; import { IFileEditService } from '../fileEdit'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ToolAccesses, @@ -73,13 +78,22 @@ export class EditTool implements BuiltinTool { @IFileEditService private readonly editor: IFileEditService, @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + // Optional so unit tests that construct the tool directly (bypassing DI) + // keep working; always registered in production scopes. + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) {} private get workspaceConfig(): WorkspaceConfig { - return { - workspaceDir: this.workspaceCtx.workDir, - additionalDirs: this.workspaceCtx.additionalDirs, - }; + // Skill roots are merged per call (v1 merged once at tool construction): + // the catalog loads asynchronously and gains roots on plugin reloads. + return extendWorkspaceWithSkillRoots( + { + workspaceDir: this.workspaceCtx.workDir, + additionalDirs: this.workspaceCtx.additionalDirs, + }, + this.skillCatalog?.catalog.getSkillRoots() ?? [], + this.env.pathClass, + ); } resolveExecution(args: EditInput): ToolExecution { 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/app/skillCatalog/skillCatalogRuntimeOptions.ts b/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts index e8ae9f86c4..40d6150ac7 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillCatalogRuntimeOptions.ts @@ -5,11 +5,15 @@ * resolved. `explicitDirs` mirrors v1's SDK `skillDirs`: when present, default * user / project discovery is skipped and the explicit directories are used as * the user source. Bound at App scope. + * + * Composition roots set it through {@link skillCatalogRuntimeOptionsSeed} + * (kap-server's `startServer({ skillDirs })`, the v2 print CLI's `--skillsDir`) + * — the registered default carries no explicit dirs. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope, registerScopedService, type ScopeSeed } from '#/_base/di/scope'; export interface ISkillCatalogRuntimeOptions { readonly _serviceBrand: undefined; @@ -25,6 +29,23 @@ export class SkillCatalogRuntimeOptions implements ISkillCatalogRuntimeOptions { constructor(readonly explicitDirs?: readonly string[]) {} } +/** + * Seed {@link ISkillCatalogRuntimeOptions} with caller-supplied explicit skill + * dirs (v1's SDK `skillDirs`). Empty/absent input seeds nothing so the + * registered default (no explicit dirs) stays in effect. + */ +export function skillCatalogRuntimeOptionsSeed( + explicitDirs: readonly string[] | undefined, +): ScopeSeed { + if (explicitDirs === undefined || explicitDirs.length === 0) return []; + return [ + [ + ISkillCatalogRuntimeOptions as ServiceIdentifier, + new SkillCatalogRuntimeOptions(explicitDirs), + ], + ]; +} + registerScopedService( LifecycleScope.App, ISkillCatalogRuntimeOptions, diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts index 83afebd4fb..e5557781d3 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/glob.ts @@ -58,6 +58,7 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { unwrapErrorCause } from '#/_base/errors/errors'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { @@ -68,6 +69,7 @@ import { } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { + extendWorkspaceWithSkillRoots, isWithinDirectory, resolvePathAccessPath, type PathClass, @@ -154,16 +156,25 @@ export class GlobTool implements BuiltinTool { @IHostProcessService private readonly processService: IHostProcessService, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ITelemetryService private readonly telemetry: ITelemetryService, + // Optional so unit tests that construct the tool directly (bypassing DI) + // keep working; always registered in production scopes. + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) { this.description = this.env.pathClass === 'win32' ? globDescription + WINDOWS_PATH_HINT : globDescription; } private get workspaceConfig(): WorkspaceConfig { - return { - workspaceDir: this.workspaceCtx.workDir, - additionalDirs: this.workspaceCtx.additionalDirs, - }; + // Skill roots are merged per call (v1 merged once at tool construction): + // the catalog loads asynchronously and gains roots on plugin reloads. + return extendWorkspaceWithSkillRoots( + { + workspaceDir: this.workspaceCtx.workDir, + additionalDirs: this.workspaceCtx.additionalDirs, + }, + this.skillCatalog?.catalog.getSkillRoots() ?? [], + this.env.pathClass, + ); } resolveExecution(args: GlobInput): ToolExecution { diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts index 18f5b1a791..736800d565 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts @@ -34,8 +34,10 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { unwrapErrorCause } from '#/_base/errors/errors'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { + extendWorkspaceWithSkillRoots, resolvePathAccessPath, type PathClass, isSensitiveFile, @@ -200,13 +202,22 @@ export class GrepTool implements BuiltinTool { @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ITelemetryService private readonly telemetry: ITelemetryService, + // Optional so unit tests that construct the tool directly (bypassing DI) + // keep working; always registered in production scopes. + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) {} private get workspace(): WorkspaceConfig { - return { - workspaceDir: this.workspaceCtx.workDir, - additionalDirs: this.workspaceCtx.additionalDirs, - }; + // Skill roots are merged per call (v1 merged once at tool construction): + // the catalog loads asynchronously and gains roots on plugin reloads. + return extendWorkspaceWithSkillRoots( + { + workspaceDir: this.workspaceCtx.workDir, + additionalDirs: this.workspaceCtx.additionalDirs, + }, + this.skillCatalog?.catalog.getSkillRoots() ?? [], + this.env.pathClass, + ); } resolveExecution(args: GrepInput): ToolExecution { diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts index 7bf51fc577..f1243c9c1a 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts @@ -28,6 +28,7 @@ import { z } from 'zod'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { unwrapErrorCause } from '#/_base/errors/errors'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ToolAccesses, @@ -36,7 +37,11 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; -import { resolvePathAccessPath, type WorkspaceConfig } from '#/tool/path-access'; +import { + extendWorkspaceWithSkillRoots, + resolvePathAccessPath, + type WorkspaceConfig, +} from '#/tool/path-access'; import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; @@ -238,13 +243,22 @@ export class ReadTool implements BuiltinTool { @IHostFileSystem private readonly fs: IHostFileSystem, @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + // Optional so unit tests that construct the tool directly (bypassing DI) + // keep working; always registered in production scopes. + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) {} private get workspaceConfig(): WorkspaceConfig { - return { - workspaceDir: this.workspaceCtx.workDir, - additionalDirs: this.workspaceCtx.additionalDirs, - }; + // Skill roots are merged per call (v1 merged once at tool construction): + // the catalog loads asynchronously and gains roots on plugin reloads. + return extendWorkspaceWithSkillRoots( + { + workspaceDir: this.workspaceCtx.workDir, + additionalDirs: this.workspaceCtx.additionalDirs, + }, + this.skillCatalog?.catalog.getSkillRoots() ?? [], + this.env.pathClass, + ); } resolveExecution(args: ReadInput): ToolExecution { diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts index 230dee0f98..bcd669f1fc 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts @@ -23,6 +23,7 @@ import { z } from 'zod'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { type HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; import { unwrapErrorCause } from '#/_base/errors/errors'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ToolAccesses, @@ -31,7 +32,11 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; -import { resolvePathAccessPath, type WorkspaceConfig } from '#/tool/path-access'; +import { + extendWorkspaceWithSkillRoots, + resolvePathAccessPath, + type WorkspaceConfig, +} from '#/tool/path-access'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; import WRITE_DESCRIPTION from './write.md?raw'; @@ -72,13 +77,22 @@ export class WriteTool implements BuiltinTool { @IHostFileSystem private readonly fs: IHostFileSystem, @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + // Optional so unit tests that construct the tool directly (bypassing DI) + // keep working; always registered in production scopes. + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, ) {} private get workspaceConfig(): WorkspaceConfig { - return { - workspaceDir: this.workspaceCtx.workDir, - additionalDirs: this.workspaceCtx.additionalDirs, - }; + // Skill roots are merged per call (v1 merged once at tool construction): + // the catalog loads asynchronously and gains roots on plugin reloads. + return extendWorkspaceWithSkillRoots( + { + workspaceDir: this.workspaceCtx.workDir, + additionalDirs: this.workspaceCtx.additionalDirs, + }, + this.skillCatalog?.catalog.getSkillRoots() ?? [], + this.env.pathClass, + ); } resolveExecution(args: WriteInput): ToolExecution { 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/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts index dfcbc5a8df..88ef57cb84 100644 --- a/packages/agent-core-v2/src/tool/path-access.ts +++ b/packages/agent-core-v2/src/tool/path-access.ts @@ -6,6 +6,9 @@ * Read/Write/Edit/Grep/Glob — canonicalization, workspace containment, * sensitive-file detection (env / credential / SSH key patterns with * explicit exemptions like `.env.example`) — and `PathSecurityError`. + * `extendWorkspaceWithSkillRoots` merges skill-catalog roots into a tool + * workspace so skill directories outside the cwd (e.g. `~/.kimi-code/skills`) + * stay reachable — the v2 port of v1's `skill/scanner.ts` helper. * Canonicalization is **lexical** only (no `realpath` / symlink following). * The guard stays host-aware: callers pass the active `IHostEnvironment` * path class so SSH paths stay POSIX even when the host Node process is @@ -242,6 +245,31 @@ export function isWithinWorkspace( return false; } +/** + * Merge skill-catalog roots into a tool workspace's `additionalDirs` so the + * file tools (Read/Write/Edit/Grep/Glob/ReadMediaFile) can reach skill + * directories outside the cwd (e.g. `~/.kimi-code/skills`). Roots already + * inside the workspace or an existing additional dir are skipped. Returns + * `workspace` unchanged when nothing was added. Port of v1's + * `skill/scanner.ts` helper — v1 applied it once at builtin-tool construction; + * v2 tools call it per execution so roots from late-loading skill sources + * (plugin reloads, ad-hoc contributions) are picked up. + */ +export function extendWorkspaceWithSkillRoots( + workspace: T, + skillRoots: readonly string[], + pathClass: PathClass = DEFAULT_PATH_CLASS, +): T { + const additionalDirs = [...workspace.additionalDirs]; + for (const root of skillRoots) { + if (isWithinDirectory(root, workspace.workspaceDir, pathClass)) continue; + if (additionalDirs.some((dir) => isWithinDirectory(root, dir, pathClass))) continue; + additionalDirs.push(root); + } + if (additionalDirs.length === workspace.additionalDirs.length) return workspace; + return { ...workspace, additionalDirs }; +} + export interface AssertPathOptions { readonly mode: PathAccessOperation; /** When true (default), also reject paths matching a sensitive-file pattern. */ diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts index bc0d51083a..1b89159def 100644 --- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts +++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts @@ -79,7 +79,11 @@ function buildTool( reg.define(IFileEditService, FileEditService); }, }); - return ix.createInstance(EditTool); + // Direct construction instead of `createInstance(EditTool)`: the optional + // trailing `@ISessionSkillCatalog` param defeats `GetLeadingNonServiceArgs` + // inference (optional tuple element), so the typed overload no longer + // applies. DI resolves the same instances for production construction. + return new EditTool(ix.get(IFileEditService), env, workspace); } function isPromiseLike( 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/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index 64f825da9e..ce83e15ad6 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -31,6 +31,7 @@ import { import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem'; import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { type GrepInput, @@ -297,6 +298,12 @@ describe('GrepTool', () => { reg.defineInstance(IHostEnvironment, createTestEnv(kaos)); reg.defineInstance(ISessionWorkspaceContext, stubWorkspaceContext('/workspace')); reg.defineInstance(ITelemetryService, noopTelemetryService); + // Registered at Session scope in production (the strict container + // throws on unresolvable deps, so the stub mirrors that). + reg.defineInstance(ISessionSkillCatalog, { + _serviceBrand: undefined, + catalog: { getSkillRoots: () => [] }, + } as unknown as ISessionSkillCatalog); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentBuiltinToolsRegistrar, AgentBuiltinToolsRegistrar); }, diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index e4378a5272..f18067ec8f 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -21,6 +21,7 @@ import { describe, expect, it, vi } from 'vitest'; import { PathSecurityError } from '#/tool/path-access'; import { MEDIA_SNIFF_BYTES } from '#/agent/media/file-type'; +import type { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { @@ -312,6 +313,27 @@ describe('ReadTool', () => { expect(readText).not.toHaveBeenCalled(); }); + it('allows relative traversal into a skill root the session catalog provides', async () => { + const { fs } = createSpiedFs('skill body'); + const skillCatalog = { + _serviceBrand: undefined, + catalog: { getSkillRoots: () => ['/skills'] }, + } as unknown as ISessionSkillCatalog; + const tool = new ReadTool( + fs, + createTestEnv(), + stubWorkspaceContext('/workspace/project'), + skillCatalog, + ); + + // Same shape as the rejection above (`../../` escapes the workspace), but + // the canonical path lands inside a catalog skill root. + const result = await execute(tool, { path: '../../skills/SKILL.md' }); + + expect(result.isError ?? false).toBe(false); + expect(result.output).toBe('1\tskill body'); + }); + it('allows explicit absolute paths outside the workspace', async () => { const { fs, readBytes, readLines } = createSpiedFs('external'); const tool = new ReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace')); 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( diff --git a/packages/agent-core-v2/test/tool/path-access.test.ts b/packages/agent-core-v2/test/tool/path-access.test.ts index 2657ff7a3b..9bc6ed5d9a 100644 --- a/packages/agent-core-v2/test/tool/path-access.test.ts +++ b/packages/agent-core-v2/test/tool/path-access.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isSensitiveFile } from '#/tool/path-access'; +import { extendWorkspaceWithSkillRoots, isSensitiveFile } from '#/tool/path-access'; describe('isSensitiveFile', () => { it('flags base .env files in any directory', () => { @@ -65,3 +65,40 @@ describe('isSensitiveFile', () => { } }); }); + +describe('extendWorkspaceWithSkillRoots', () => { + const workspace = { workspaceDir: '/repo', additionalDirs: ['/extra'] }; + + it('returns the workspace unchanged when there are no skill roots', () => { + expect(extendWorkspaceWithSkillRoots(workspace, [])).toBe(workspace); + }); + + it('appends roots outside the workspace and existing additional dirs', () => { + expect(extendWorkspaceWithSkillRoots(workspace, ['/home/user/.kimi-code/skills'])).toEqual({ + workspaceDir: '/repo', + additionalDirs: ['/extra', '/home/user/.kimi-code/skills'], + }); + }); + + it('skips roots already inside the workspace dir or an additional dir', () => { + expect( + extendWorkspaceWithSkillRoots(workspace, ['/repo/.agents/skills', '/extra/skills']), + ).toBe(workspace); + }); + + it('dedupes roots that repeat or nest inside a just-added root', () => { + expect( + extendWorkspaceWithSkillRoots(workspace, ['/skills', '/skills', '/skills/sub']), + ).toEqual({ workspaceDir: '/repo', additionalDirs: ['/extra', '/skills'] }); + }); + + it('compares case-insensitively on win32 path class', () => { + expect( + extendWorkspaceWithSkillRoots( + { workspaceDir: 'C:/repo', additionalDirs: [] }, + ['c:/Repo/skills'], + 'win32', + ).additionalDirs, + ).toEqual([]); + }); +}); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 392d97548b..1284d0fa20 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -17,6 +17,7 @@ import { resolveConfigPath, resolveKimiHome, resolveLoggingConfig, + skillCatalogRuntimeOptionsSeed, type Scope, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; @@ -101,6 +102,13 @@ export interface ServerStartOptions { readonly rpcToken?: string; /** Extra scope seeds applied at bootstrap (e.g. a host-provided `ISessionModelResolver`). */ readonly seeds?: ScopeSeed; + /** + * Explicit skill directories for this process (v1's SDK `skillDirs`): when + * non-empty, default user / project skill discovery is skipped and these + * directories serve as the user skill source for every session. Applied to + * all sessions the server hosts — for embedding hosts, not per-session use. + */ + readonly skillDirs?: readonly string[]; /** * Directory of the built Kimi web UI (`dist-web`). When set, `GET /` and the * `/*` SPA fallback serve these assets (auth-exempt, matching v1). Omit to run @@ -224,6 +232,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { const overridden = server.core.accessor.get(IHostRequestHeaders); expect(overridden.headers['User-Agent']).toBe('custom-host/9.9'); }); + + it('seeds explicit skill dirs into the core scope when skillDirs is provided', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-skills-')); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + skillDirs: ['/skills/explicit'], + }); + expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toEqual([ + '/skills/explicit', + ]); + + // Without skillDirs the registered default carries no explicit dirs. + await server.close(); + server = undefined; + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toBeUndefined(); + }); }); function silentLogger() {