From 0b2e803d5e71afaab45212bb2ee6117ecbf8bbc9 Mon Sep 17 00:00:00 2001 From: Liu Zhongnuo Date: Fri, 7 Aug 2026 13:11:18 +0800 Subject: [PATCH] feat(sdk): expose global MCP auth status (#2706) --- .changeset/quiet-mcp-auth-status.md | 5 ++ packages/agent-core/src/rpc/core-api.ts | 14 ++++ packages/agent-core/src/rpc/core-impl.ts | 25 ++++++++ packages/node-sdk/src/kimi-harness.ts | 5 ++ packages/node-sdk/src/rpc.ts | 6 ++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 32 +++++++++- packages/node-sdk/src/types.ts | 2 + packages/node-sdk/test/mcp-config.test.ts | 44 +++++++++++++ .../node-sdk/test/sdk-rpc-client-v2.test.ts | 64 +++++++++++++++++++ packages/node-sdk/test/v1-v2-parity.test.ts | 49 ++++++++++++++ 10 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-mcp-auth-status.md diff --git a/.changeset/quiet-mcp-auth-status.md b/.changeset/quiet-mcp-auth-status.md new file mode 100644 index 000000000..891a3e990 --- /dev/null +++ b/.changeset/quiet-mcp-auth-status.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Expose persisted MCP authorization status without starting an OAuth flow. diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index a0997fb10..fbcf50177 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -352,6 +352,17 @@ export interface GlobalMcpServerNamePayload { readonly name: string; } +export type GlobalMcpServerAuthState = + | 'not-applicable' + | 'bearer-token' + | 'oauth-required' + | 'oauth-authorized'; + +export interface GlobalMcpServerAuthStatus { + readonly name: string; + readonly authStatus: GlobalMcpServerAuthState; +} + export type BeginGlobalMcpServerAuthResult = | { readonly status: 'already-authorized' } | { @@ -537,6 +548,9 @@ export interface CoreAPI extends SessionAPIWithId { setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig; removeKimiProvider: (payload: RemoveKimiProviderPayload) => KimiConfig; listGlobalMcpServers: (payload: EmptyPayload) => readonly GlobalMcpServerConfig[]; + listGlobalMcpServerAuthStatuses: ( + payload: EmptyPayload, + ) => readonly GlobalMcpServerAuthStatus[]; addGlobalMcpServer: (payload: PutGlobalMcpServerPayload) => readonly GlobalMcpServerConfig[]; updateGlobalMcpServer: (payload: PutGlobalMcpServerPayload) => readonly GlobalMcpServerConfig[]; removeGlobalMcpServer: (payload: GlobalMcpServerNamePayload) => readonly GlobalMcpServerConfig[]; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 319b55551..d750cf429 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -100,6 +100,8 @@ import type { EnterSwarmPayload, GoalSnapshot, GoalToolResult, + GlobalMcpServerAuthState, + GlobalMcpServerAuthStatus, GlobalMcpServerConfig, GlobalMcpServerNamePayload, GlobalMcpServerTestResult, @@ -766,6 +768,18 @@ export class KimiCore implements PromisableMethods { return this.globalMcpConfig.list(); } + async listGlobalMcpServerAuthStatuses( + _input?: EmptyPayload, + ): Promise { + const servers = await this.globalMcpConfig.list(); + return Promise.all( + servers.map(async (server) => ({ + name: server.name, + authStatus: await this.globalMcpServerAuthState(server), + })), + ); + } + async addGlobalMcpServer( { server }: PutGlobalMcpServerPayload, ): Promise { @@ -858,6 +872,17 @@ export class KimiCore implements PromisableMethods { } } + private async globalMcpServerAuthState( + server: GlobalMcpServerConfig, + ): Promise { + if (server.transport === 'stdio') return 'not-applicable'; + if (server.bearerTokenEnvVar !== undefined) return 'bearer-token'; + if (server.auth !== 'oauth') return 'not-applicable'; + return this.globalMcpOAuth.hasTokens(server.name, server.url) + ? 'oauth-authorized' + : 'oauth-required'; + } + prompt({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).prompt(payload); } diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 3aa1843de..ae5343849 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -19,6 +19,7 @@ import type { ExportSessionResult, ForkSessionInput, GetConfigOptions, + GlobalMcpServerAuthStatus, KimiConfig, KimiConfigPatch, KimiHostIdentity, @@ -393,6 +394,10 @@ export class KimiHarness { return this.rpc.listGlobalMcpServers(); } + async listMcpServerAuthStatuses(): Promise { + return this.rpc.listGlobalMcpServerAuthStatuses(); + } + async addMcpServer(server: McpServerConfig): Promise { return this.rpc.addGlobalMcpServer(server); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index e33199d2a..270c9d1d7 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -35,6 +35,7 @@ import type { CreateGoalInput, ForkSessionInput, GetConfigOptions, + GlobalMcpServerAuthStatus, McpServerConfig, GoalSnapshot, GoalToolResult, @@ -315,6 +316,11 @@ export abstract class SDKRpcClientBase { return rpc.listGlobalMcpServers({}); } + async listGlobalMcpServerAuthStatuses(): Promise { + const rpc = await this.getRpc(); + return rpc.listGlobalMcpServerAuthStatuses({}); + } + async addGlobalMcpServer(server: McpServerConfig): Promise { const rpc = await this.getRpc(); return rpc.addGlobalMcpServer({ server }); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index a0d82e662..8e880b306 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -75,7 +75,8 @@ * `handlePrintMainTurnCompleted` → rebuilt over the v2 print-mode config * helpers and the session's per-agent task services (no v2 service owns * the print policy). - * - `listGlobalMcpServers` / `addGlobalMcpServer` / `updateGlobalMcpServer` / + * - `listGlobalMcpServers` / `listGlobalMcpServerAuthStatuses` / + * `addGlobalMcpServer` / `updateGlobalMcpServer` / * `removeGlobalMcpServer` / `beginGlobalMcpServerAuth` / * `completeGlobalMcpServerAuth` / `cancelGlobalMcpServerAuth` / * `resetGlobalMcpServerAuth` / `testGlobalMcpServer` → the v1 user-global @@ -272,6 +273,8 @@ import type { ForkSessionInput, GetConfigOptions, GetCronTasksResult, + GlobalMcpServerAuthState, + GlobalMcpServerAuthStatus, GoalSnapshot, GoalToolResult, JsonObject, @@ -2105,6 +2108,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return this.globalMcpConfig.list(); } + override async listGlobalMcpServerAuthStatuses(): Promise< + readonly GlobalMcpServerAuthStatus[] + > { + const servers = await this.globalMcpConfig.list(); + const oauth = new McpOAuthService({ + store: createMcpOAuthStore(this.engineAccessor.get(IAtomicDocumentStore)), + }); + return Promise.all( + servers.map(async (server) => ({ + name: server.name, + authStatus: await this.globalMcpServerAuthState(server, oauth), + })), + ); + } + override async addGlobalMcpServer( server: McpServerConfig, ): Promise { @@ -2214,6 +2232,18 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } } + private async globalMcpServerAuthState( + server: McpServerConfig, + oauth: McpOAuthService, + ): Promise { + if (server.transport === 'stdio') return 'not-applicable'; + if (server.bearerTokenEnvVar !== undefined) return 'bearer-token'; + if (server.auth !== 'oauth') return 'not-applicable'; + return (await oauth.hasTokens(server.name, server.url)) + ? 'oauth-authorized' + : 'oauth-required'; + } + /** * Through the session scope (the seeded `ISessionMcpHandle.connectionManager` * — the workspace handler's one shared manager). This is a live snapshot: diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index bc5f4bbc4..8ce05461d 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -39,6 +39,8 @@ export type { GoalSnapshot, GoalStatus, GoalToolResult, + GlobalMcpServerAuthState, + GlobalMcpServerAuthStatus, KimiConfig, KimiConfigPatch, LoopControl, diff --git a/packages/node-sdk/test/mcp-config.test.ts b/packages/node-sdk/test/mcp-config.test.ts index ae36419e9..e2aefd0d2 100644 --- a/packages/node-sdk/test/mcp-config.test.ts +++ b/packages/node-sdk/test/mcp-config.test.ts @@ -17,6 +17,8 @@ import { } from '#/index'; import { afterEach, describe, expect, it } from 'vitest'; +import { McpOAuthService } from '../../agent-core/src/mcp/oauth/service'; + const tempDirs: string[] = []; const stdioFixture = join( import.meta.dirname, @@ -211,6 +213,48 @@ describe('standalone MCP check (connection result)', () => { }); describe('MCP OAuth facade (host-controlled browser flow)', () => { + it('reports persisted authorization without starting an OAuth flow', async () => { + const homeDir = await makeTempDir(); + const authorizedUrl = 'https://authorized.example.test/mcp'; + new McpOAuthService({ kimiHomeDir: homeDir }) + .getProvider('oauth-authorized', authorizedUrl) + .saveTokens({ access_token: 'test-access-token', token_type: 'Bearer' }); + await writeMcpConfig(homeDir, { + mcpServers: { + stdio: { command: 'local-command' }, + plain: { transport: 'http', url: 'https://plain.example.test/mcp' }, + bearer: { + transport: 'http', + url: 'https://bearer.example.test/mcp', + bearerTokenEnvVar: 'EXAMPLE_MCP_TOKEN', + }, + 'oauth-required': { + transport: 'http', + url: 'https://required.example.test/mcp', + auth: 'oauth', + }, + 'oauth-authorized': { + transport: 'http', + url: authorizedUrl, + auth: 'oauth', + }, + }, + }); + const harness = createKimiHarness({ homeDir }); + + try { + await expect(harness.listMcpServerAuthStatuses()).resolves.toEqual([ + { name: 'stdio', authStatus: 'not-applicable' }, + { name: 'plain', authStatus: 'not-applicable' }, + { name: 'bearer', authStatus: 'bearer-token' }, + { name: 'oauth-required', authStatus: 'oauth-required' }, + { name: 'oauth-authorized', authStatus: 'oauth-authorized' }, + ]); + } finally { + await harness.close(); + } + }); + it('resets authorization for a configured remote server', async () => { const homeDir = await makeTempDir(); const harness = createKimiHarness({ homeDir }); diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 160131655..2ff76fb08 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -29,6 +29,8 @@ import { IHostRequestHeaders, } from '@moonshot-ai/agent-core-v2'; +import { McpOAuthService } from '../../agent-core/src/mcp/oauth/service'; + import { TEST_IDENTITY } from './test-identity'; import { recordingTelemetry, type TelemetryRecord } from './telemetry'; @@ -51,6 +53,68 @@ async function makeHarness(): Promise<{ harness: KimiHarness; homeDir: string }> } describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { + it('reports global MCP authorization from the persisted v2 credential store', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const authorizedUrl = 'https://authorized.example.test/mcp'; + const requiredUrl = 'https://required.example.test/mcp'; + const externalOAuth = new McpOAuthService({ kimiHomeDir: homeDir }); + externalOAuth + .getProvider('oauth-authorized', authorizedUrl) + .saveTokens({ access_token: 'test-access-token', token_type: 'Bearer' }); + await writeFile( + join(homeDir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + stdio: { command: 'local-command' }, + plain: { transport: 'http', url: 'https://plain.example.test/mcp' }, + bearer: { + transport: 'http', + url: 'https://bearer.example.test/mcp', + bearerTokenEnvVar: 'EXAMPLE_MCP_TOKEN', + }, + 'oauth-required': { + transport: 'http', + url: requiredUrl, + auth: 'oauth', + }, + 'oauth-authorized': { + transport: 'http', + url: authorizedUrl, + auth: 'oauth', + }, + }, + }), + 'utf-8', + ); + const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }); + + try { + await expect(harness.listMcpServerAuthStatuses()).resolves.toEqual([ + { name: 'stdio', authStatus: 'not-applicable' }, + { name: 'plain', authStatus: 'not-applicable' }, + { name: 'bearer', authStatus: 'bearer-token' }, + { name: 'oauth-required', authStatus: 'oauth-required' }, + { name: 'oauth-authorized', authStatus: 'oauth-authorized' }, + ]); + + externalOAuth + .getProvider('oauth-required', requiredUrl) + .saveTokens({ access_token: 'new-test-access-token', token_type: 'Bearer' }); + externalOAuth.invalidate('oauth-authorized', authorizedUrl, 'tokens'); + + await expect(harness.listMcpServerAuthStatuses()).resolves.toEqual([ + { name: 'stdio', authStatus: 'not-applicable' }, + { name: 'plain', authStatus: 'not-applicable' }, + { name: 'bearer', authStatus: 'bearer-token' }, + { name: 'oauth-required', authStatus: 'oauth-authorized' }, + { name: 'oauth-authorized', authStatus: 'oauth-required' }, + ]); + } finally { + await harness.close(); + } + }); + it('seeds the host request headers (User-Agent + X-Msh-*) into the engine', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 4dc9c236d..8b738f8c1 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -21,6 +21,8 @@ import { getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; +import { McpOAuthService } from '../../agent-core/src/mcp/oauth/service'; + import { createKimiHarness, createKimiHarnessV2, @@ -3461,6 +3463,53 @@ async function expectSameMcpRejection( } describe('v1↔v2 global MCP parity', () => { + it('classifies global MCP authorization identically from persisted credentials', async () => { + const authorizedUrl = 'https://authorized.example.test/mcp'; + const pair = await makeGlobalMcpParityPair({ + mcpServers: { + stdio: { command: 'local-command' }, + plain: { transport: 'http', url: 'https://plain.example.test/mcp' }, + bearer: { + transport: 'http', + url: 'https://bearer.example.test/mcp', + bearerTokenEnvVar: 'EXAMPLE_MCP_TOKEN', + }, + 'oauth-required': { + transport: 'http', + url: 'https://required.example.test/mcp', + auth: 'oauth', + }, + 'oauth-authorized': { + transport: 'http', + url: authorizedUrl, + auth: 'oauth', + }, + }, + }); + for (const homeDir of [pair.v1HomeDir, pair.v2HomeDir]) { + new McpOAuthService({ kimiHomeDir: homeDir }) + .getProvider('oauth-authorized', authorizedUrl) + .saveTokens({ access_token: 'test-access-token', token_type: 'Bearer' }); + } + + try { + const [v1Statuses, v2Statuses] = await Promise.all([ + pair.v1.listGlobalMcpServerAuthStatuses(), + pair.v2.listGlobalMcpServerAuthStatuses(), + ]); + expect(v2Statuses).toEqual(v1Statuses); + expect(v1Statuses).toEqual([ + { name: 'stdio', authStatus: 'not-applicable' }, + { name: 'plain', authStatus: 'not-applicable' }, + { name: 'bearer', authStatus: 'bearer-token' }, + { name: 'oauth-required', authStatus: 'oauth-required' }, + { name: 'oauth-authorized', authStatus: 'oauth-authorized' }, + ]); + } finally { + await closeGlobalMcpPair(pair); + } + }); + it('CRUD round-trips identically and writes byte-identical mcp.json files', async () => { const pair = await makeGlobalMcpParityPair({ custom: { keep: true },