mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-23 15:46:26 +00:00
* feat(agent-core): unify the v1 MCP management plane - McpServerRegistry: one config view over global (layered mcp.json), plugin (manifests, read-only, final effective config), and caller (SDK-injected) servers; name collisions keep both entries. - Write plane: add/update/removeGlobalMcpServer mutate the user-level file and push into live sessions; getGlobalMcpServer returns the effective config; mutations of read-only entries are rejected. - testGlobalMcpServer accepts an inline config; addSessionMcpServer connects a server in one live session with an optional persist flag; reconnect accepts a replacement config and re-resolves via the registry. - One process-wide McpOAuthService shared with every session: obtained_at stamps, offline token state, single-flight and proactive refresh, and credential events. Sessions self-subscribe in the constructor, so even initializing sessions see every event; token writes serialize through the process-local OAuthTokenTransaction per credential identity. - inspectAppMcpServers + locator-addressed begin/complete/cancel/reset cover plugin servers; inspection output redacts env/headers to sorted key lists; locator OAuth ops reject ambiguous shared runtime names. - The legacy auth-status surface reads the registry (offline by default, verify=true probes) and never mutates credentials. - VS Code panel receives source/origin/mutable and hides mutating actions on read-only entries. - v2 client facade in node-sdk mirrors the surface over agent-core-v2 (plugin inventory stays v1-only for now). * fix(agent-core): close the v1 MCP live-session reconciliation gaps Recompute each live session's MCP target from the registry's runtime resolution (enabled plugin > project layer > user file; caller injection shadows everything) behind every config mutation, instead of per-path patching: shadowed file layers recover when a plugin winner is disabled or removed, removing a user-level entry resurrects its project-layer shadow, disabled plugin descriptors no longer block removals, persisted session adds validate against the session's project layer and broadcast to other live sessions, and per-session sync failures are logged with context. Session status entries and read-only management entries now report redacted config views (envKeys/headerKeys instead of literal env/headers values); core-internal reconciliation compares full configs via the connection manager's raw-entry accessor. OAuth: interactive flows are serialized per credential (concurrent begins join the in-flight flow instead of clobbering its PKCE/state), a malformed credential meta sidecar no longer aborts core start, grants inside the refresh-ahead window refresh immediately while far-future grants re-arm through a max-length timer, and the service shuts its timers and flows down with KimiCore/SDKRpcClient close. * fix(agent-core): route the proactive MCP OAuth refresh through the token transaction refreshNow ran its /token request with the SDK default fetch, outside the credential-serializing OAuthTokenTransaction that every other token write uses; a slower response carrying an older rotating refresh token could overwrite a newer grant written by a concurrent transport-side refresh. * fix(agent-core): keep disabled MCP servers out of auth-state classification The unified mcpServerAuthState dropped the previous enabled short-circuit, so a disabled oauth-flagged server reported oauth-required — or was even probed over the network — instead of not-applicable. * fix(kimi-code-sdk): short-circuit disabled MCP servers in the v2 auth-status classifier The v2 parity copy of v1's mcpServerAuthState missed the same enabled guard v1 just regained; a disabled oauth-flagged entry would report oauth-required (or be probed). The parity suite now pins the disabled case on both engines. * fix(kimi-code): refresh the VS Code MCP list with the workspace cwd after mutations The add/update/remove RPCs return a cwd-less management list, so the webview broadcast dropped project-layer entries until the next full load; re-list with the workspace cwd after every mutation instead. * fix(agent-core): keep SDK token saves matched to the OAuth token transaction saveTokens stamped obtained_at onto a fresh object before calling tokenTransaction.save, so it never matched the exact payload the transaction recorded for a grant fetch; the consume path was dead and every save re-wrote. Between the fetch and the SDK callback an intervening clear could then be overwritten — the resurrected grant came back after a reset. The write callback stamps the durable record instead. * fix(agent-core): reject ambiguous legacy name-based MCP auth lookups The legacy begin/reset auth RPCs took the registry's first name match, silently starting OAuth for one entry of a runtime-name collision while the locator path refused the same ambiguity; align them on the shared uniqueness rule and point callers at the locator-addressed variants. * fix(agent-core): propagate registry errors during live-session MCP sync resolveMcpRuntimeTarget collapsed every registry failure into "no target": a project config file that turned malformed mid-session made sync treat a still-configured server as gone (tearing down the live connection) and made config-aware reconnects report "no longer configured" instead of the actionable config error. Absence still resolves to undefined; malformed config now propagates — per-session sync logs and keeps the entry, and reconnect surfaces config.invalid. * fix(agent-core): close the remaining registry-error and ambiguity gaps The management guard lookup mapped every registry failure to "absent", so a malformed project config let a persisted session add write a user-level entry over an unknown state; only not-found is a miss now. And the name-only connection test now shares the auth paths' uniqueness rule instead of probing the first match of a runtime-name collision. * fix(agent-core): probe the enabled MCP entry under a disabled-name collision The name-only connection test counted enabled matches for its ambiguity guard but still probed the first registry match, and the file layers list before plugins. With a disabled file entry shadowing an enabled plugin of the same runtime name, Test probed the disabled entry instead of the one a live session would run. Select the sole enabled match, falling back to the first entry only when every match is disabled so it reports as disabled. * fix(agent-core): let session-local MCP adds shadow plugin entries Caller injection shadows every registry source at session start, plugins included, and reconciliation leaves caller entries untouched; the live non-persist add path rejected plugin-owned names anyway, so SDK clients could not apply the same per-session override without a restart. Gate the plugin-source rejection on persist: session-local adds connect as caller, while persisted adds stay rejected as user-level writes behind a read-only owner. * fix(agent-core): normalize session MCP names before connecting The persisted store trims server names, but addSessionMcpServer used the raw name for the live connect and cross-session reconciliation: a padded name persisted under the trimmed key while the requesting session ran and reconciled the raw one, and a blank name connected with no identity at all. Normalize once up front (rejecting blank) so the store write, the session entry, and reconciliation agree on the same server. * fix(agent-core,node-sdk): close the collision-selection and probe-freshness gaps The legacy name-only auth resolver started from the first registry match, so a disabled file-layer shadow plus an enabled plugin of the same runtime name was misread as an ambiguity conflict; select the sole enabled match before judging ambiguity, exactly like the test probe path. On the v2 client, addSessionMcpServer connected the raw name while the store wrote the trimmed key — normalize once for both, and route the verify-triggered auth probes through the per-call OAuth service instead of the cached one whose providers snapshot tokens at construction, so a grant saved after the first probe is honored. * fix(agent-core): normalize global MCP mutation names and guard disabled reconnect swaps The global add/update/remove mutations guarded and reconciled with the raw server name while the store persisted the trimmed key, so a padded name left live sessions unreconciled and could slip past the plugin read-only guard; normalize once before lookup, persistence, and reconciliation. And a config-carrying reconnect assigned the replacement before the disabled check fired, leaving a connected entry that reported the disabled config; reject disabled replacements before mutating, keeping the same error. * fix(agent-core): skip proactive refresh while an interactive flow owns the credential refreshNow reset the shared provider's flow state before and after the token request; when a proactive timer (or a manual refresh) fired while beginAuthorization was waiting on the browser callback for the same store key, that wiped the redirect URL, PKCE verifier, and state the in-flight flow needed — complete() then failed the exchange even though the user authorized. Refresh now skips when an interactive flow is active for the credential: the flow delivers fresh tokens on completion, and the 401 transport path is the backstop if it fails. * fix(agent-core): allow global MCP adds over disabled plugin descriptors A disabled plugin entry is absent from the runtime target, but the read-only guard still treated it as the owner, so a user-level fallback could only exist if it predated the plugin disable. Relax the shared guard: disabled plugin descriptors never block mutations (disabled project entries still shadow the user file and keep their rejection). * fix(node-sdk): close the v2 session-MCP parity gaps A v2 reconnect with an explicit enabled:false replacement config used connect()'s upsert semantics — closing the live client and reporting success where v1's manager reconnect rejects before applying anything; reject disabled replacements up front with the same error. And a persisted v2 session add never consulted the workspace config, so a same-named project-layer entry was silently shadowed: the user-level write never takes effect while the direct workspace-manager upsert displaces the project config for every live session. Resolve the workspace layers and reject like v1's read-only rule. * fix(agent-core): keep __proto__-named MCP servers through config parsing A z.record() parse rebuilds its output via property assignment, so a server literally named __proto__ hit the prototype setter and vanished before validation; the layer merge then repeated the same trap with plain object accumulators. Parse the server map entry-by-entry over the JSON own keys and accumulate into null-prototype maps, so session startup and the unified registry keep the declared server and its origin. * fix(node-sdk): begin v2 MCP auth against a fresh OAuth service The v2 begin path ran through the cached globalMcpOAuth, whose providers snapshot tokens at construction: a grant another process saved (or reset) after that cache materialized was invisible, so begin could open a browser flow over a valid grant, or report already-authorized off a removed one. Build the service per call — the read path and the verify probes already do — and route the status list through the same helper. The test fixture grows a real token endpoint honoring one rotating refresh token; the regression fails against the cached-service implementation on v2. * fix(agent-core): broadcast SDK-driven MCP token invalidations to live sessions * test(agent-core-v2): give the no-op reconnect test runtime plumbing The branch added the case against a bare McpConnectionManager, but #2961 made stdio connects resolve the runtime through runtimeResolver, matching every other case in the file.
173 lines
5.6 KiB
TypeScript
173 lines
5.6 KiB
TypeScript
import {
|
|
createServer,
|
|
type IncomingMessage,
|
|
type ServerResponse,
|
|
} from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
|
|
export interface McpAuthStatusServer {
|
|
readonly authToken: string;
|
|
readonly refreshToken: string;
|
|
readonly plainUrl: string;
|
|
readonly oauthUrl: string;
|
|
readonly unavailableUrl: string;
|
|
requestCount(pathname: string): number;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
/** The one refresh token the `/token` endpoint honors. */
|
|
const REFRESH_TOKEN = 'valid-test-refresh-token';
|
|
|
|
export async function startMcpAuthStatusServer(): Promise<McpAuthStatusServer> {
|
|
const authToken = 'valid-test-access-token';
|
|
let baseUrl = '';
|
|
const requestCounts = new Map<string, number>();
|
|
const server = createServer((request, response) => {
|
|
const pathname = new URL(request.url ?? '/', baseUrl).pathname;
|
|
requestCounts.set(pathname, (requestCounts.get(pathname) ?? 0) + 1);
|
|
void handleRequest(request, response, baseUrl, authToken).catch((error: unknown) => {
|
|
if (!response.headersSent) response.writeHead(500);
|
|
response.end(String(error));
|
|
});
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const { port } = server.address() as AddressInfo;
|
|
baseUrl = `http://127.0.0.1:${port}`;
|
|
return {
|
|
authToken,
|
|
refreshToken: REFRESH_TOKEN,
|
|
plainUrl: `${baseUrl}/plain`,
|
|
oauthUrl: `${baseUrl}/oauth`,
|
|
unavailableUrl: `${baseUrl}/unavailable`,
|
|
requestCount: (pathname) => requestCounts.get(pathname) ?? 0,
|
|
close: () =>
|
|
new Promise<void>((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error === undefined) resolve();
|
|
else reject(error);
|
|
});
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function handleRequest(
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
baseUrl: string,
|
|
authToken: string,
|
|
): Promise<void> {
|
|
const url = new URL(request.url ?? '/', baseUrl);
|
|
if (url.pathname === '/unavailable') {
|
|
response.writeHead(503).end('Temporarily unavailable');
|
|
return;
|
|
}
|
|
if (url.pathname === '/token' && request.method === 'POST') {
|
|
// Token endpoint: a valid refresh grant rotates into a fresh access
|
|
// token; anything else is rejected, so probes of stale credentials still
|
|
// land on needs-auth.
|
|
const rawBody = await readBody(request);
|
|
const params = new URLSearchParams(rawBody);
|
|
if (
|
|
params.get('grant_type') === 'refresh_token' &&
|
|
params.get('refresh_token') === REFRESH_TOKEN
|
|
) {
|
|
sendJson(response, {
|
|
access_token: authToken,
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
refresh_token: REFRESH_TOKEN,
|
|
});
|
|
return;
|
|
}
|
|
response.writeHead(400, { 'content-type': 'application/json' });
|
|
response.end(JSON.stringify({ error: 'invalid_grant' }));
|
|
return;
|
|
}
|
|
if (url.pathname === '/.well-known/oauth-protected-resource') {
|
|
sendJson(response, {
|
|
resource: `${baseUrl}/oauth`,
|
|
authorization_servers: [baseUrl],
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
url.pathname === '/.well-known/oauth-authorization-server' ||
|
|
url.pathname === '/.well-known/openid-configuration'
|
|
) {
|
|
sendJson(response, {
|
|
issuer: baseUrl,
|
|
authorization_endpoint: `${baseUrl}/authorize`,
|
|
token_endpoint: `${baseUrl}/token`,
|
|
registration_endpoint: `${baseUrl}/register`,
|
|
response_types_supported: ['code'],
|
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
code_challenge_methods_supported: ['S256'],
|
|
token_endpoint_auth_methods_supported: ['none'],
|
|
});
|
|
return;
|
|
}
|
|
if (url.pathname === '/register' && request.method === 'POST') {
|
|
const metadata = await readJson(request);
|
|
sendJson(response, { client_id: 'test-client', ...metadata }, 201);
|
|
return;
|
|
}
|
|
if (url.pathname === '/oauth' && request.headers.authorization !== `Bearer ${authToken}`) {
|
|
response.writeHead(401, {
|
|
'content-type': 'application/json',
|
|
'www-authenticate': `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`,
|
|
});
|
|
response.end(JSON.stringify({ error: 'unauthorized' }));
|
|
return;
|
|
}
|
|
if (request.method !== 'POST') {
|
|
response.writeHead(405).end('Method not allowed');
|
|
return;
|
|
}
|
|
const message = (await readJson(request)) as {
|
|
readonly id?: string | number;
|
|
readonly method?: string;
|
|
};
|
|
if (message.id === undefined) {
|
|
response.writeHead(202).end();
|
|
return;
|
|
}
|
|
const result =
|
|
message.method === 'initialize'
|
|
? {
|
|
protocolVersion: '2025-03-26',
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: 'auth-status', version: '0.0.1' },
|
|
}
|
|
: message.method === 'tools/list'
|
|
? { tools: [] }
|
|
: {};
|
|
sendJson(response, { jsonrpc: '2.0', id: message.id, result });
|
|
}
|
|
|
|
async function readBody(request: AsyncIterable<unknown>): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) {
|
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk as Uint8Array));
|
|
}
|
|
return Buffer.concat(chunks).toString('utf8');
|
|
}
|
|
|
|
async function readJson(request: AsyncIterable<unknown>): Promise<Record<string, unknown>> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) {
|
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk as Uint8Array));
|
|
}
|
|
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>;
|
|
}
|
|
|
|
function sendJson(
|
|
response: ServerResponse,
|
|
body: unknown,
|
|
status = 200,
|
|
): void {
|
|
response.writeHead(status, { 'content-type': 'application/json' });
|
|
response.end(JSON.stringify(body));
|
|
}
|