feat(sdk): expose global MCP auth status (#2706)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions

This commit is contained in:
Liu Zhongnuo 2026-08-07 13:11:18 +08:00 committed by GitHub
parent 7cd64766c8
commit 0b2e803d5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 245 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": patch
---
Expose persisted MCP authorization status without starting an OAuth flow.

View file

@ -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[];

View file

@ -100,6 +100,8 @@ import type {
EnterSwarmPayload,
GoalSnapshot,
GoalToolResult,
GlobalMcpServerAuthState,
GlobalMcpServerAuthStatus,
GlobalMcpServerConfig,
GlobalMcpServerNamePayload,
GlobalMcpServerTestResult,
@ -766,6 +768,18 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.globalMcpConfig.list();
}
async listGlobalMcpServerAuthStatuses(
_input?: EmptyPayload,
): Promise<readonly GlobalMcpServerAuthStatus[]> {
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<readonly GlobalMcpServerConfig[]> {
@ -858,6 +872,17 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
}
}
private async globalMcpServerAuthState(
server: GlobalMcpServerConfig,
): Promise<GlobalMcpServerAuthState> {
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<PromptPayload>) {
return this.sessionApi(sessionId).prompt(payload);
}

View file

@ -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<readonly GlobalMcpServerAuthStatus[]> {
return this.rpc.listGlobalMcpServerAuthStatuses();
}
async addMcpServer(server: McpServerConfig): Promise<readonly McpServerConfig[]> {
return this.rpc.addGlobalMcpServer(server);
}

View file

@ -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<readonly GlobalMcpServerAuthStatus[]> {
const rpc = await this.getRpc();
return rpc.listGlobalMcpServerAuthStatuses({});
}
async addGlobalMcpServer(server: McpServerConfig): Promise<readonly McpServerConfig[]> {
const rpc = await this.getRpc();
return rpc.addGlobalMcpServer({ server });

View file

@ -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<readonly McpServerConfig[]> {
@ -2214,6 +2232,18 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
}
}
private async globalMcpServerAuthState(
server: McpServerConfig,
oauth: McpOAuthService,
): Promise<GlobalMcpServerAuthState> {
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:

View file

@ -39,6 +39,8 @@ export type {
GoalSnapshot,
GoalStatus,
GoalToolResult,
GlobalMcpServerAuthState,
GlobalMcpServerAuthStatus,
KimiConfig,
KimiConfigPatch,
LoopControl,

View file

@ -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 });

View file

@ -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);

View file

@ -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 },