Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2304554
feat(agent-core-v2): add the unified MCP management plane
7Sageer Aug 17, 2026
29ca69a
merge: main into feat/v2-mcp-management-plane
7Sageer Aug 19, 2026
07b03ea
fix(agent-core-v2): settle early and cancelled MCP OAuth callbacks
7Sageer Aug 19, 2026
21b3011
refactor(node-sdk): write session MCP persists through the engine con…
7Sageer Aug 19, 2026
4715a8b
refactor(agent-core-v2): strip comments from the MCP management plane…
7Sageer Aug 19, 2026
4805c7b
fix(agent-core-v2): harden MCP management readiness
7Sageer Aug 19, 2026
a912121
test(node-sdk): cover offline MCP auth statuses
7Sageer Aug 19, 2026
39ea35b
fix(agent-core-v2): isolate stdio MCP probes
7Sageer Aug 19, 2026
430879b
fix(klient): normalize MCP OAuth errors
7Sageer Aug 19, 2026
fce8716
fix(mcp): honor workspace CRUD context and refresh timing
7Sageer Aug 19, 2026
03e1e11
fix(mcp): drain OAuth refreshes during shutdown
7Sageer Aug 19, 2026
ce7695c
fix(mcp): guard CRUD across registry collisions
7Sageer Aug 19, 2026
4024a5e
fix(mcp): canonicalize trust and refresh scheduling
7Sageer Aug 20, 2026
99d4b5d
fix(mcp): close callback listener on setup failure
7Sageer Aug 20, 2026
e49897d
fix(mcp): preserve trust and oauth behavior
7Sageer Aug 20, 2026
38c229d
fix(oauth): retain refresh tokens after SDK saves
7Sageer Aug 20, 2026
3951e2b
fix(oauth): stop proactive sweep during shutdown
7Sageer Aug 20, 2026
a55288b
fix: await MCP workspace reconciliation
7Sageer Aug 20, 2026
35aeaad
fix: serialize MCP OAuth and trust cleanup
7Sageer Aug 20, 2026
69e1d79
fix: reject persisted MCP plugin collisions
7Sageer Aug 20, 2026
6ed3fa0
fix: reconcile MCP workspaces concurrently
7Sageer Aug 20, 2026
c4a9386
fix(mcp): check project-layer trust at the queried cwd
7Sageer Aug 20, 2026
47e3f66
fix(mcp): expire abandoned OAuth flows after an idle timeout
7Sageer Aug 20, 2026
cd8a10d
fix(mcp): keep mutable user entries writable past read-only collisions
7Sageer Aug 20, 2026
3cf1061
fix(mcp): abort the auth::complete long poll on client disconnect
7Sageer Aug 20, 2026
2e9ed91
fix(mcp): map OAuth flow failures to wire code 40929
7Sageer Aug 20, 2026
5d2f6b4
docs(mcp): note probe credential effects and plane semantics
7Sageer Aug 20, 2026
cb0ada9
chore: add the SDK changeset for MCP management cwd params
7Sageer Aug 20, 2026
68d4e7a
feat(mcp): expose the management plane without the experimental flag
7Sageer Aug 20, 2026
234c689
fix(mcp): preserve auth management semantics
7Sageer Aug 20, 2026
f8da2c9
fix(agent-core-v2): bound MCP OAuth auth-server requests and the shut…
7Sageer Aug 21, 2026
aae4ce7
fix(node-sdk): restate engine MCP management errors as KimiError
7Sageer Aug 21, 2026
24bc9b1
fix(agent-core-v2): preserve shared OAuth flow lifetime
7Sageer Aug 21, 2026
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
5 changes: 5 additions & 0 deletions .changeset/respect-mcp-management-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Respect workspace trust and configuration readiness when managing MCP servers.
5 changes: 5 additions & 0 deletions .changeset/sdk-mcp-management-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": patch
---

Add an optional cwd parameter to global MCP management and authorization methods for project-layer-aware operations. MCP auth-status reads preserve implicit OAuth detection by default; pass verify: false for stored-credential-only classification or verify: true to verify every candidate.
4 changes: 4 additions & 0 deletions packages/agent-core-v2/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / at

One accepted exception: `features/tower/protocol` manages the `.tower/` directory inside the *user's* repository (worktree slots, comms files, activity log) — workspace content, not engine state — and is a verbatim port of the v1 protocol whose semantics (atomic tmp+rename, real `git` CLI for worktrees/merges) are the feature. It keeps direct `node:fs` / `node:child_process` access; do not "modernize" it onto the Stores above without a dedicated migration.

## MCP management plane

The App scope owns the process-wide MCP management surface, split across three domains: `mcpConfig` (`src/app/mcpConfig/` — the `[mcp]` config section, the layered mcp.json loader with per-entry origins, `IMcpConfigStore` as the single write point for the user-level `mcp.json`, `IMcpOAuthStore` credential persistence, and `IMcpOAuthService`, the process-wide OAuth orchestrator with credential events, single-flight refresh, and proactive refresh timers), `mcpRegistry` (`IMcpRegistryService` — the unified read view over the file layers and plugin manifests with `source`/`origin`/`mutable`; runtime-name collisions keep both entries, and runtime resolution ranks an enabled plugin entry above the file layers; a project layer joins the view only when the queried cwd itself is trusted, matching what the workspace runtime would load), and `mcpManagement` (`IMcpManagementService` — guarded CRUD (a mutable user-level entry stays writable past a read-only collision), connection-test probes, the locator-addressed inspection/auth-status surface, and locator-addressed OAuth begin/complete/cancel/reset with ambiguity rejection plus an idle timeout that cancels abandoned flows). The engine services and the edge exposure (kap-server routes, klient facade) are ungated. On the Workspace side, `workspaceMcpConfig` merges the same sources (same plugin-over-file precedence), watches the files and plugin reloads, follows the store's `onDidWrite` for immediate management-plane reloads, and publishes fingerprint diffs that `workspaceMcp` applies to the handler-shared `McpConnectionManager` (tombstones for removals); `workspaceMcp` also subscribes the OAuth service's credential events to reconnect affected entries. Session overlays (`session/mcp`) keep caller-injected ephemeral servers session-local.

## Session index

`ISessionIndex` (`src/app/sessionIndex/`, App scope) serves session list/resume reads over two paths: the authoritative directory scan (`sessionIndexSource`, always correct, linear) and the minidb-backed derived read model (`IQueryStore` at `<home>/cache/query-store`, keyset-paged, `O(log N + limit)`), gated by the `persistence_minidb_readmodel` flag (default ON; roll back via `KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL=false` or the `[experimental]` config section). The read model has an explicit lifecycle — `uninitialized → preparing → ready/degraded` via `prepare()`/`status()`; reads while preparing answer from the authoritative store immediately and fold the `ISessionIndexMirror` queue in for read-your-writes; the first list shares one single-flight authoritative scan with the initial projection. The query-store is structural-only — text-index definitions are rejected at definition level, so session operations never touch the global full-text index (`<home>/search-index`, owned by kap-server's search surface).
Expand Down
71 changes: 47 additions & 24 deletions packages/agent-core-v2/src/_base/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,24 @@ export type IWaitUntilData<T> = Omit<T, 'waitUntil' | 'signal'>;
export class AsyncEmitter<T extends IWaitUntil> extends Emitter<T> {
private _asyncDeliveryQueue?: LinkedList<[(event: T) => void, IWaitUntilData<T>]>;

async fireAsyncConcurrent(data: IWaitUntilData<T>, signal: AbortSignal): Promise<void> {
if (this.isDisposed || this._listeners === undefined || signal.aborted) {
return;
}
const snapshot = Array.from(this._listeners);
await Promise.all(
snapshot.map((entry) =>
this.deliverAsync(
(event) => {
entry.listener.call(entry.thisArg, event);
},
data,
signal,
),
),
);
}

async fireAsync(data: IWaitUntilData<T>, signal: AbortSignal): Promise<void> {
if (this.isDisposed || this._listeners === undefined) {
return;
Expand All @@ -129,32 +147,37 @@ export class AsyncEmitter<T extends IWaitUntil> extends Emitter<T> {

while (this._asyncDeliveryQueue.size > 0 && !signal.aborted) {
const [deliver, eventData] = this._asyncDeliveryQueue.shift()!;
const thenables: Promise<unknown>[] = [];

const event = {
...eventData,
signal,
waitUntil: (p: Promise<unknown>): void => {
if (Object.isFrozen(thenables)) {
throw new Error('waitUntil can NOT be called asynchronously');
}
thenables.push(p);
},
} as T;

try {
deliver(event);
} catch (error) {
onUnexpectedError(error);
continue;
}
await this.deliverAsync(deliver, eventData, signal);
}
}

void Object.freeze(thenables);
const settled = await Promise.allSettled(thenables);
for (const result of settled) {
if (result.status === 'rejected') {
onUnexpectedError(result.reason);
private async deliverAsync(
deliver: (event: T) => void,
data: IWaitUntilData<T>,
signal: AbortSignal,
): Promise<void> {
const thenables: Promise<unknown>[] = [];
const event = {
...data,
signal,
waitUntil: (p: Promise<unknown>): void => {
if (Object.isFrozen(thenables)) {
throw new Error('waitUntil can NOT be called asynchronously');
}
thenables.push(p);
},
} as T;
try {
deliver(event);
} catch (error) {
onUnexpectedError(error);
return;
}
void Object.freeze(thenables);
const settled = await Promise.allSettled(thenables);
for (const result of settled) {
if (result.status === 'rejected') {
onUnexpectedError(result.reason);
}
}
}
Expand Down
197 changes: 197 additions & 0 deletions packages/agent-core-v2/src/app/mcpConfig/configLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { dirname, isAbsolute, join, normalize, resolve } from 'pathe';

import { resolveKimiHome } from '#/app/bootstrap/bootstrap';
import { findGitWorkTree } from '#/app/git/workTree';
import { ErrorCodes, Error2 } from '#/errors';
import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { OsFsErrors, HostFsError } from '#/os/interface/hostFsErrors';

export interface McpJsonPaths {
readonly user: string;
readonly projectRoot: string;
readonly project: string;
}

export interface ResolveMcpJsonPathsInput {
readonly fs: IHostFileSystem;
readonly cwd: string;
readonly homeDir?: string;
}

export async function resolveMcpJsonPaths(input: ResolveMcpJsonPathsInput): Promise<McpJsonPaths> {
const start = normalize(input.cwd);
const projectRoot = (await findGitWorkTree(input.fs, start))?.root ?? start;

return {
user: join(resolveKimiHome(input.homeDir), 'mcp.json'),
projectRoot: join(projectRoot, '.mcp.json'),
project: join(input.cwd, '.kimi-code', 'mcp.json'),
};
}

export interface LoadMcpServersInput {
readonly fs: IHostFileSystem;
readonly cwd: string;
readonly homeDir?: string;
readonly includeProject?: boolean;
}

export interface LoadMcpServersDetailedResult {
/** Later layers override earlier ones with the same key. */
readonly servers: Record<string, McpServerConfig>;
/** The file each effective entry was last defined in. */
readonly origins: Record<string, string>;
}

export async function loadMcpServers(
input: LoadMcpServersInput,
): Promise<Record<string, McpServerConfig>> {
return (await loadMcpServersDetailed(input)).servers;
}

/**
* {@link loadMcpServers} plus the defining-file origin of every effective
* entry, for management surfaces that show where a server came from.
*/
export async function loadMcpServersDetailed(
input: LoadMcpServersInput,
): Promise<LoadMcpServersDetailedResult> {
const paths = await resolveMcpJsonPaths(input);
if (input.includeProject === false) {
const user = await readMcpJson(input.fs, paths.user);
return { servers: user, origins: mapValuesToPath(user, paths.user) };
}
const layers: readonly [path: string, servers: Record<string, McpServerConfig>][] =
await Promise.all([
readMcpJson(input.fs, paths.user),
readMcpJson(input.fs, paths.projectRoot, { stdioCwdBase: dirname(paths.projectRoot) }),
readMcpJson(input.fs, paths.project),
]).then(([user, projectRoot, project]) => [
[paths.user, user],
[paths.projectRoot, projectRoot],
[paths.project, project],
]);
const servers: Record<string, McpServerConfig> = Object.create(null);
const origins: Record<string, string> = Object.create(null);
for (const [path, layer] of layers) {
for (const [name, config] of Object.entries(layer)) {
servers[name] = config;
origins[name] = path;
}
}
return { servers, origins };
}

interface ReadMcpJsonOptions {
readonly stdioCwdBase?: string;
}

async function readMcpJson(
fs: IHostFileSystem,
filePath: string,
options: ReadMcpJsonOptions = {},
): Promise<Record<string, McpServerConfig>> {
let text: string;
try {
text = await fs.readText(filePath);
} catch (error: unknown) {
if (isFileNotFound(error)) return {};
throw new Error2(
ErrorCodes.CONFIG_INVALID,
`Failed to read ${filePath}: ${describeError(error)}`,
{
cause: error,
},
);
}

if (text.trim().length === 0) return {};

let data: unknown;
try {
data = JSON.parse(text);
} catch (error: unknown) {
throw new Error2(
ErrorCodes.CONFIG_INVALID,
`Invalid JSON in ${filePath}: ${describeError(error)}`,
{
cause: error,
},
);
}

try {
return normalizeMcpServers(parseMcpJsonServers(data), options);
} catch (error: unknown) {
throw new Error2(
ErrorCodes.CONFIG_INVALID,
`Invalid MCP server config in ${filePath}: ${describeError(error)}`,
{
cause: error,
},
);
}
}

function parseMcpJsonServers(data: unknown): Record<string, McpServerConfig> {
if (!isRecord(data)) {
throw new Error('expected a JSON object');
}
if (!('mcpServers' in data)) return {};
const raw = data['mcpServers'];
if (!isRecord(raw)) {
throw new Error('"mcpServers" must be an object');
}
return Object.fromEntries(
Object.entries(raw).map(([name, value]) => [name, McpServerConfigSchema.parse(value)]),
);
}

function normalizeMcpServers(
servers: Record<string, McpServerConfig>,
options: ReadMcpJsonOptions,
): Record<string, McpServerConfig> {
const stdioCwdBase = options.stdioCwdBase;
if (stdioCwdBase === undefined) return servers;

return Object.fromEntries(
Object.entries(servers).map(([name, config]) => [
name,
normalizeStdioCwd(config, stdioCwdBase),
]),
);
}

function normalizeStdioCwd(config: McpServerConfig, cwdBase: string): McpServerConfig {
if (config.transport !== 'stdio') return config;
const cwd = config.cwd === undefined ? cwdBase : resolvePath(cwdBase, config.cwd);
return { ...config, cwd };
}

function mapValuesToPath(
servers: Record<string, McpServerConfig>,
path: string,
): Record<string, string> {
const origins: Record<string, string> = Object.create(null);
for (const name of Object.keys(servers)) {
origins[name] = path;
}
return origins;
}

function resolvePath(base: string, value: string): string {
return isAbsolute(value) ? normalize(value) : resolve(base, value);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isFileNotFound(error: unknown): boolean {
return error instanceof HostFsError && error.code === OsFsErrors.codes.OS_FS_NOT_FOUND;
}

function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
Loading