mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-07 07:49:02 +00:00
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
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:
parent
7cd64766c8
commit
0b2e803d5e
10 changed files with 245 additions and 1 deletions
5
.changeset/quiet-mcp-auth-status.md
Normal file
5
.changeset/quiet-mcp-auth-status.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code-sdk": patch
|
||||
---
|
||||
|
||||
Expose persisted MCP authorization status without starting an OAuth flow.
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export type {
|
|||
GoalSnapshot,
|
||||
GoalStatus,
|
||||
GoalToolResult,
|
||||
GlobalMcpServerAuthState,
|
||||
GlobalMcpServerAuthStatus,
|
||||
KimiConfig,
|
||||
KimiConfigPatch,
|
||||
LoopControl,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue