mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
fix(mcp): preserve auth management semantics
This commit is contained in:
parent
68d4e7ab14
commit
234c689bba
19 changed files with 476 additions and 135 deletions
|
|
@ -2,4 +2,4 @@
|
|||
"@moonshot-ai/kimi-code-sdk": patch
|
||||
---
|
||||
|
||||
Add an optional cwd parameter to the global MCP server management methods for project-layer-aware reads and guarded writes. On the v2 engine, MCP auth-status results are classified offline by default; pass verify: true to probe servers for implicit OAuth requirements.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -93,7 +93,10 @@ export interface McpServerAuthFlowHandle {
|
|||
}
|
||||
|
||||
export interface McpAuthStatusQuery extends McpRegistryQuery {
|
||||
/** Online verification: probe a real connection instead of offline classification. */
|
||||
/**
|
||||
* Omitted preserves implicit OAuth detection, `false` stays offline, and
|
||||
* `true` verifies every OAuth candidate through a real connection.
|
||||
*/
|
||||
readonly verify?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -123,9 +126,10 @@ export interface IMcpManagementService {
|
|||
|
||||
/**
|
||||
* Legacy auth-status surface: per-server OAuth state over the registry
|
||||
* catalog. Offline by default (stored-grant classification only, never
|
||||
* mutates credentials); `verify: true` probes a real connection, which may
|
||||
* refresh or invalidate stored credentials and broadcast the events.
|
||||
* catalog. Omitted preserves the legacy implicit-OAuth probe for unpinned
|
||||
* servers without stored credentials; `verify: false` is fully offline;
|
||||
* `verify: true` probes every candidate. Probes may refresh or invalidate
|
||||
* stored credentials and broadcast the events.
|
||||
*/
|
||||
listAuthStatuses(query?: McpAuthStatusQuery): Promise<readonly McpServerAuthStatus[]>;
|
||||
|
||||
|
|
@ -136,17 +140,23 @@ export interface IMcpManagementService {
|
|||
* shared by enabled entries cannot be probed (or credentialed)
|
||||
* unambiguously and reports `unavailable`.
|
||||
*/
|
||||
inspectServers(targets?: readonly McpServerLocator[]): Promise<readonly McpServerInspection[]>;
|
||||
inspectServers(
|
||||
targets?: readonly McpServerLocator[],
|
||||
query?: McpRegistryQuery,
|
||||
): Promise<readonly McpServerInspection[]>;
|
||||
|
||||
/**
|
||||
* Resolve a legacy name-only auth target: exactly one enabled entry may
|
||||
* own the runtime name — under a collision the caller cannot tell which
|
||||
* credential the flow acts on, so it rejects instead of guessing.
|
||||
*/
|
||||
resolveServerByName(name: string): Promise<McpServerLocator>;
|
||||
resolveServerByName(name: string, query?: McpRegistryQuery): Promise<McpServerLocator>;
|
||||
|
||||
/** Begin an interactive OAuth flow for a remote server. */
|
||||
beginServerAuth(locator: McpServerLocator): Promise<McpServerAuthBeginResult>;
|
||||
beginServerAuth(
|
||||
locator: McpServerLocator,
|
||||
query?: McpRegistryQuery,
|
||||
): Promise<McpServerAuthBeginResult>;
|
||||
|
||||
/** Await the browser callback and finish the code exchange. Unknown flow → request.invalid. */
|
||||
completeServerAuth(
|
||||
|
|
@ -158,7 +168,7 @@ export interface IMcpManagementService {
|
|||
cancelServerAuth(handle: Pick<McpServerAuthFlowHandle, 'flowId'>): Promise<void>;
|
||||
|
||||
/** Clear stored credentials; the invalidation event reaches live sessions. */
|
||||
resetServerAuth(locator: McpServerLocator): Promise<void>;
|
||||
resetServerAuth(locator: McpServerLocator, query?: McpRegistryQuery): Promise<void>;
|
||||
}
|
||||
|
||||
export const IMcpManagementService: ServiceIdentifier<IMcpManagementService> =
|
||||
|
|
|
|||
|
|
@ -235,20 +235,20 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
|
|||
async listAuthStatuses(query: McpAuthStatusQuery = {}): Promise<readonly McpServerAuthStatus[]> {
|
||||
await this.waitForReadiness();
|
||||
const entries = await this.registry.list({ cwd: query.cwd });
|
||||
const verify = query.verify === true;
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => ({
|
||||
name: entry.name,
|
||||
authStatus: await this.serverAuthState(entry, query.cwd, verify),
|
||||
authStatus: await this.serverAuthState(entry, query.cwd, query.verify),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async inspectServers(
|
||||
targets?: readonly McpServerLocator[],
|
||||
query: McpRegistryQuery = {},
|
||||
): Promise<readonly McpServerInspection[]> {
|
||||
await this.waitForReadiness();
|
||||
const catalog = await this.serverDescriptors();
|
||||
const catalog = await this.serverDescriptors(query);
|
||||
const descriptors = selectServerDescriptors(catalog, targets);
|
||||
const inspections = await this.inspectServerDescriptors(descriptors, catalog);
|
||||
return inspections.map((inspection) => ({
|
||||
|
|
@ -257,18 +257,21 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
|
|||
}));
|
||||
}
|
||||
|
||||
async resolveServerByName(name: string): Promise<McpServerLocator> {
|
||||
await this.registry.get(name);
|
||||
const catalog = await this.serverDescriptors();
|
||||
async resolveServerByName(name: string, query: McpRegistryQuery = {}): Promise<McpServerLocator> {
|
||||
await this.registry.get(name, query);
|
||||
const catalog = await this.serverDescriptors(query);
|
||||
const matches = catalog.filter((candidate) => candidate.runtimeName === name);
|
||||
const descriptor = matches.find((candidate) => candidate.enabled) ?? matches[0]!;
|
||||
this.requireUnambiguousRuntimeName(catalog, descriptor);
|
||||
return descriptor.locator;
|
||||
}
|
||||
|
||||
async beginServerAuth(locator: McpServerLocator): Promise<McpServerAuthBeginResult> {
|
||||
async beginServerAuth(
|
||||
locator: McpServerLocator,
|
||||
query: McpRegistryQuery = {},
|
||||
): Promise<McpServerAuthBeginResult> {
|
||||
await this.waitForReadiness();
|
||||
const server = await this.resolveServer(locator);
|
||||
const server = await this.resolveServer(locator, query);
|
||||
const config = requireOAuthMcpConfig(server.runtimeName, server.config);
|
||||
try {
|
||||
const flow = await this.oauth.beginAuthorization(server.runtimeName, config.url);
|
||||
|
|
@ -329,21 +332,24 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
async resetServerAuth(locator: McpServerLocator): Promise<void> {
|
||||
async resetServerAuth(locator: McpServerLocator, query: McpRegistryQuery = {}): Promise<void> {
|
||||
await this.waitForReadiness();
|
||||
const server = await this.resolveServer(locator);
|
||||
const server = await this.resolveServer(locator, query);
|
||||
const config = requireRemoteMcpConfig(server.runtimeName, server.config);
|
||||
await this.oauth.invalidate(server.runtimeName, config.url);
|
||||
}
|
||||
|
||||
private async serverDescriptors(): Promise<readonly McpServerRuntimeDescriptor[]> {
|
||||
return (await this.registry.list()).map((entry) => serverDescriptor(entry));
|
||||
private async serverDescriptors(
|
||||
query: McpRegistryQuery = {},
|
||||
): Promise<readonly McpServerRuntimeDescriptor[]> {
|
||||
return (await this.registry.list(query)).map((entry) => serverDescriptor(entry));
|
||||
}
|
||||
|
||||
private async resolveServer(
|
||||
locator: McpServerLocator,
|
||||
query: McpRegistryQuery,
|
||||
): Promise<McpServerRuntimeDescriptor> {
|
||||
const catalog = await this.serverDescriptors();
|
||||
const catalog = await this.serverDescriptors(query);
|
||||
const server = selectServerDescriptors(catalog, [locator])[0]!;
|
||||
this.requireUnambiguousRuntimeName(catalog, server);
|
||||
return server;
|
||||
|
|
@ -370,7 +376,7 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
|
|||
private async serverAuthState(
|
||||
entry: McpRegistryEntry,
|
||||
cwd: string | undefined,
|
||||
verify: boolean,
|
||||
verify: boolean | undefined,
|
||||
): Promise<McpServerAuthState> {
|
||||
const server = entry.config;
|
||||
if (server.enabled === false) return 'not-applicable';
|
||||
|
|
@ -394,7 +400,9 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
|
|||
return offline();
|
||||
});
|
||||
|
||||
return verify ? probe() : offline();
|
||||
if (verify === true) return probe();
|
||||
if (verify === false || tokens.hasTokens || server.auth === 'oauth') return offline();
|
||||
return probe();
|
||||
}
|
||||
|
||||
private async inspectServerDescriptors(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import { IMcpOAuthService } from '#/app/mcpConfig/oauthService';
|
|||
import { ISessionManager } from '#/app/sessionManager/sessionManager';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import type { McpServerConfig } from '#/mcpCore/config-schema';
|
||||
import { McpConnectionManager, type McpConnectionView } from '#/mcpCore/connection-manager';
|
||||
import {
|
||||
McpConnectionManager,
|
||||
type McpConnectionView,
|
||||
type McpServerEntry,
|
||||
} from '#/mcpCore/connection-manager';
|
||||
import type { McpOAuthEvent, McpOAuthService } from '#/mcpCore/oauth/service';
|
||||
import { canonicalMcpOAuthResource } from '#/mcpCore/oauth/store';
|
||||
import { ISessionEphemeralMcpServers } from '#/session/mcp/ephemeralMcpServers';
|
||||
|
|
@ -178,15 +182,24 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ
|
|||
if (entry.status === 'disabled' || entry.status === 'removed') return;
|
||||
if (entry.status === 'pending') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const unsubscribe = manager.onStatusChange((next) => {
|
||||
if (next.name !== event.serverName || next.status === 'pending') return;
|
||||
let unsubscribe = (): void => {};
|
||||
let settled = false;
|
||||
const reconnect = (next: McpServerEntry | undefined): void => {
|
||||
if (settled) return;
|
||||
if (next !== undefined && (next.name !== event.serverName || next.status === 'pending')) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
unsubscribe();
|
||||
if (next.status === 'disabled' || next.status === 'removed') {
|
||||
if (next === undefined || next.status === 'disabled' || next.status === 'removed') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
void manager.reconnectAfterCurrent(event.serverName).then(resolve, reject);
|
||||
});
|
||||
};
|
||||
unsubscribe = manager.onStatusChange(reconnect);
|
||||
if (settled) unsubscribe();
|
||||
else reconnect(manager.get(event.serverName));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -910,7 +910,7 @@ describe('McpManagementService', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('classifies unpinned servers without a stored grant offline', async () => {
|
||||
it('classifies unpinned servers without a stored grant offline when verify is false', async () => {
|
||||
const server = await startCountingServer();
|
||||
await management.addServer({ name: 'plain', transport: 'http', url: server.url });
|
||||
await management.addServer({
|
||||
|
|
@ -920,13 +920,22 @@ describe('McpManagementService', () => {
|
|||
auth: 'oauth',
|
||||
});
|
||||
|
||||
await expect(management.listAuthStatuses()).resolves.toEqual([
|
||||
await expect(management.listAuthStatuses({ verify: false })).resolves.toEqual([
|
||||
{ name: 'plain', authStatus: 'not-applicable' },
|
||||
{ name: 'challenged', authStatus: 'oauth-required' },
|
||||
]);
|
||||
expect(server.requestCount()).toBe(0);
|
||||
}, 20000);
|
||||
|
||||
it('detects an implicit OAuth challenge when verify is omitted', async () => {
|
||||
const gated = await startGatedServer();
|
||||
await management.addServer({ name: 'detected', transport: 'http', url: gated.url });
|
||||
|
||||
await expect(management.listAuthStatuses()).resolves.toEqual([
|
||||
{ name: 'detected', authStatus: 'oauth-required' },
|
||||
]);
|
||||
}, 20000);
|
||||
|
||||
it('verify settles a stored-but-rejected grant as oauth-expired through a real probe', async () => {
|
||||
const gated = await startGatedServer();
|
||||
await management.addServer({
|
||||
|
|
@ -949,6 +958,37 @@ describe('McpManagementService', () => {
|
|||
});
|
||||
|
||||
describe('inspectServers', () => {
|
||||
it('includes trusted project-layer entries when cwd is provided', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'kimi-mcp-management-inspect-project-'));
|
||||
tempDirs.push(project);
|
||||
await mkdir(join(project, '.kimi-code'), { recursive: true });
|
||||
await writeFile(
|
||||
join(project, '.kimi-code', 'mcp.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
local: {
|
||||
transport: 'http',
|
||||
url: 'https://project.example.test/mcp',
|
||||
headers: { 'X-Key': 'secret' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const inspections = await management.inspectServers(undefined, { cwd: project });
|
||||
|
||||
expect(inspections).toEqual([
|
||||
expect.objectContaining({
|
||||
serverId: 'global:local',
|
||||
runtimeName: 'local',
|
||||
canonicalUrl: 'https://project.example.test/mcp',
|
||||
editable: false,
|
||||
authStatus: 'not-applicable',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists the locator-addressed catalog with offline classifications and redacted configs', async () => {
|
||||
const plain = await startHttpServer();
|
||||
await management.addServer({ name: 'plain', transport: 'http', url: plain.url });
|
||||
|
|
@ -1090,6 +1130,22 @@ describe('McpManagementService', () => {
|
|||
});
|
||||
|
||||
describe('resolveServerByName', () => {
|
||||
it('resolves a project-layer-only name when cwd is provided', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'kimi-mcp-management-resolve-project-'));
|
||||
tempDirs.push(project);
|
||||
await mkdir(join(project, '.kimi-code'), { recursive: true });
|
||||
await writeFile(
|
||||
join(project, '.kimi-code', 'mcp.json'),
|
||||
JSON.stringify({ mcpServers: { local: { command: process.execPath } } }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await expect(management.resolveServerByName('local', { cwd: project })).resolves.toEqual({
|
||||
source: 'global',
|
||||
name: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a unique global name to its locator', async () => {
|
||||
await management.addServer(stdioServer('alpha'));
|
||||
|
||||
|
|
@ -1153,6 +1209,71 @@ describe('McpManagementService', () => {
|
|||
});
|
||||
|
||||
describe('OAuth operations', () => {
|
||||
it('begins authorization against the project-layer URL when cwd is provided', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'kimi-mcp-management-begin-project-'));
|
||||
tempDirs.push(project);
|
||||
await mkdir(join(project, '.kimi-code'), { recursive: true });
|
||||
await writeFile(
|
||||
join(project, '.kimi-code', 'mcp.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
oauthable: {
|
||||
transport: 'http',
|
||||
url: 'https://project.example.test/mcp',
|
||||
auth: 'oauth',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const cancel = vi.fn(async () => undefined);
|
||||
const begin = vi.spyOn(oauth, 'beginAuthorization').mockResolvedValue({
|
||||
authorizationUrl: new URL('https://project.example.test/authorize'),
|
||||
complete: vi.fn(async () => undefined),
|
||||
cancel,
|
||||
});
|
||||
|
||||
const result = await management.beginServerAuth(
|
||||
{ source: 'global', name: 'oauthable' },
|
||||
{ cwd: project },
|
||||
);
|
||||
|
||||
expect(begin).toHaveBeenCalledWith('oauthable', 'https://project.example.test/mcp');
|
||||
if (result.status === 'authorization-required') {
|
||||
await management.cancelServerAuth({ flowId: result.flowId });
|
||||
}
|
||||
});
|
||||
|
||||
it('resets credentials for the project-layer URL when cwd is provided', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'kimi-mcp-management-reset-project-'));
|
||||
tempDirs.push(project);
|
||||
await mkdir(join(project, '.kimi-code'), { recursive: true });
|
||||
await writeFile(
|
||||
join(project, '.kimi-code', 'mcp.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
oauthable: {
|
||||
transport: 'http',
|
||||
url: 'https://project.example.test/mcp',
|
||||
auth: 'oauth',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const invalidate = vi.spyOn(oauth, 'invalidate').mockResolvedValue(undefined);
|
||||
|
||||
await management.resetServerAuth(
|
||||
{ source: 'global', name: 'oauthable' },
|
||||
{ cwd: project },
|
||||
);
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith(
|
||||
'oauthable',
|
||||
'https://project.example.test/mcp',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects begin for entries that cannot run an OAuth flow', async () => {
|
||||
await management.addServer(stdioServer('local-tool'));
|
||||
await management.addServer({
|
||||
|
|
|
|||
|
|
@ -621,6 +621,38 @@ describe('WorkspaceMcpService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reconnects when a pending entry settles before the status listener is attached', async () => {
|
||||
const service = createService();
|
||||
manager = service.connectionManager();
|
||||
await service.ready;
|
||||
vi.spyOn(McpConnectionManager.prototype, 'get')
|
||||
.mockReturnValueOnce({
|
||||
name: 'notion',
|
||||
transport: 'http',
|
||||
status: 'pending',
|
||||
toolCount: 0,
|
||||
})
|
||||
.mockReturnValue({
|
||||
name: 'notion',
|
||||
transport: 'http',
|
||||
status: 'needs-auth',
|
||||
toolCount: 0,
|
||||
});
|
||||
vi.spyOn(McpConnectionManager.prototype, 'getRemoteServerUrl').mockReturnValue(SERVER_URL);
|
||||
vi.spyOn(McpConnectionManager.prototype, 'onStatusChange').mockReturnValue(() => undefined);
|
||||
const reconnectAfterCurrent = vi
|
||||
.spyOn(McpConnectionManager.prototype, 'reconnectAfterCurrent')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await oauthService
|
||||
.getProvider('notion', SERVER_URL)
|
||||
.saveTokens({ access_token: 'a', token_type: 'Bearer' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(reconnectAfterCurrent).toHaveBeenCalledWith('notion');
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a client-scope invalidation as flow-local churn', async () => {
|
||||
const service = createService();
|
||||
manager = service.connectionManager();
|
||||
|
|
|
|||
|
|
@ -426,10 +426,12 @@ export type McpServerLocator =
|
|||
|
||||
export interface McpServerLocatorPayload {
|
||||
readonly locator: McpServerLocator;
|
||||
readonly cwd?: string;
|
||||
}
|
||||
|
||||
export interface InspectAppMcpServersPayload {
|
||||
readonly targets?: readonly McpServerLocator[];
|
||||
readonly cwd?: string;
|
||||
}
|
||||
|
||||
export type GlobalMcpServerAuthState =
|
||||
|
|
@ -449,9 +451,10 @@ export interface GlobalMcpServerAuthStatus {
|
|||
export interface ListGlobalMcpServerAuthStatusesPayload {
|
||||
readonly cwd?: string;
|
||||
/**
|
||||
* Verify online: run a real connection probe for OAuth-capable servers so
|
||||
* an expired/revoked grant surfaces as `oauth-expired` instead of the
|
||||
* offline `oauth-authorized` guess.
|
||||
* Omitted preserves implicit OAuth detection for unpinned servers without
|
||||
* stored credentials. `false` stays fully offline; `true` verifies every
|
||||
* OAuth-capable server so an expired/revoked grant surfaces as
|
||||
* `oauth-expired` instead of the offline `oauth-authorized` guess.
|
||||
*/
|
||||
readonly verify?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -855,11 +855,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
): Promise<readonly GlobalMcpServerAuthStatus[]> {
|
||||
await this.awaitMcpRegistryReady();
|
||||
const entries = await this.mcpRegistry.list({ cwd: input?.cwd });
|
||||
const verify = input?.verify === true;
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => ({
|
||||
name: entry.name,
|
||||
authStatus: await this.mcpServerAuthState(entry, input?.cwd, verify),
|
||||
authStatus: await this.mcpServerAuthState(entry, input?.cwd, input?.verify),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
@ -1016,15 +1015,16 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
}
|
||||
|
||||
async beginGlobalMcpServerAuth(
|
||||
{ name }: GlobalMcpServerNamePayload,
|
||||
{ name, cwd }: GlobalMcpServerNamePayload,
|
||||
): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
return this.beginAppMcpServerAuth(await this.resolveLegacyNamedAppMcpServer(name));
|
||||
return this.beginAppMcpServerAuth(await this.resolveLegacyNamedAppMcpServer(name, cwd));
|
||||
}
|
||||
|
||||
async beginMcpServerAuth({
|
||||
locator,
|
||||
cwd,
|
||||
}: McpServerLocatorPayload): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
return this.beginAppMcpServerAuth(await this.resolveAppMcpServer(locator));
|
||||
return this.beginAppMcpServerAuth(await this.resolveAppMcpServer(locator, cwd));
|
||||
}
|
||||
|
||||
private async beginAppMcpServerAuth(
|
||||
|
|
@ -1086,14 +1086,14 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
await active.flow.cancel();
|
||||
}
|
||||
|
||||
async resetGlobalMcpServerAuth({ name }: GlobalMcpServerNamePayload): Promise<void> {
|
||||
async resetGlobalMcpServerAuth({ name, cwd }: GlobalMcpServerNamePayload): Promise<void> {
|
||||
// The legacy name-based surface resolves through the registry too, so a
|
||||
// plugin runtime name works here as well.
|
||||
await this.appMcpServerDescriptorReset(await this.resolveLegacyNamedAppMcpServer(name));
|
||||
await this.appMcpServerDescriptorReset(await this.resolveLegacyNamedAppMcpServer(name, cwd));
|
||||
}
|
||||
|
||||
async resetMcpServerAuth({ locator }: McpServerLocatorPayload): Promise<void> {
|
||||
await this.appMcpServerDescriptorReset(await this.resolveAppMcpServer(locator));
|
||||
async resetMcpServerAuth({ locator, cwd }: McpServerLocatorPayload): Promise<void> {
|
||||
await this.appMcpServerDescriptorReset(await this.resolveAppMcpServer(locator, cwd));
|
||||
}
|
||||
|
||||
private async appMcpServerDescriptorReset(
|
||||
|
|
@ -1107,17 +1107,20 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
|
||||
async inspectAppMcpServers({
|
||||
targets,
|
||||
cwd,
|
||||
}: InspectAppMcpServersPayload): Promise<readonly AppMcpServerInspection[]> {
|
||||
const catalog = await this.appMcpServerDescriptors();
|
||||
const catalog = await this.appMcpServerDescriptors(cwd);
|
||||
const descriptors = selectAppMcpServerDescriptors(catalog, targets);
|
||||
const inspections = await this.inspectAppMcpServerDescriptors(descriptors, catalog);
|
||||
return inspections.map(sanitizeAppMcpServerInspection);
|
||||
}
|
||||
|
||||
/** The registry catalog in the locator-addressed shape, with full configs. */
|
||||
private async appMcpServerDescriptors(): Promise<readonly AppMcpServerRuntimeDescriptor[]> {
|
||||
private async appMcpServerDescriptors(
|
||||
cwd?: string,
|
||||
): Promise<readonly AppMcpServerRuntimeDescriptor[]> {
|
||||
await this.awaitMcpRegistryReady();
|
||||
return (await this.mcpRegistry.list()).map((entry) => this.appMcpServerDescriptor(entry));
|
||||
return (await this.mcpRegistry.list({ cwd })).map((entry) => this.appMcpServerDescriptor(entry));
|
||||
}
|
||||
|
||||
private appMcpServerDescriptor(entry: McpRegistryEntry): AppMcpServerRuntimeDescriptor {
|
||||
|
|
@ -1142,8 +1145,9 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
|
||||
private async resolveAppMcpServer(
|
||||
locator: McpServerLocator,
|
||||
cwd?: string,
|
||||
): Promise<AppMcpServerRuntimeDescriptor> {
|
||||
const catalog = await this.appMcpServerDescriptors();
|
||||
const catalog = await this.appMcpServerDescriptors(cwd);
|
||||
const server = selectAppMcpServerDescriptors(catalog, [locator])[0]!;
|
||||
this.requireUnambiguousRuntimeName(catalog, server);
|
||||
return server;
|
||||
|
|
@ -1157,11 +1161,12 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
*/
|
||||
private async resolveLegacyNamedAppMcpServer(
|
||||
name: string,
|
||||
cwd?: string,
|
||||
): Promise<AppMcpServerRuntimeDescriptor> {
|
||||
await this.awaitMcpRegistryReady();
|
||||
// get() first, preserving its not-found error for unknown names.
|
||||
await this.mcpRegistry.get(name);
|
||||
const catalog = await this.appMcpServerDescriptors();
|
||||
await this.mcpRegistry.get(name, { cwd });
|
||||
const catalog = await this.appMcpServerDescriptors(cwd);
|
||||
const matches = catalog.filter((candidate) => candidate.runtimeName === name);
|
||||
// The sole enabled owner wins over disabled shadows (matching the runtime
|
||||
// and the connection-test path); ambiguity is then judged among the
|
||||
|
|
@ -1357,7 +1362,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
private async mcpServerAuthState(
|
||||
entry: McpRegistryEntry,
|
||||
cwd: string | undefined,
|
||||
verify: boolean,
|
||||
verify: boolean | undefined,
|
||||
): Promise<GlobalMcpServerAuthState> {
|
||||
const server = entry.config;
|
||||
// A disabled server never participates in OAuth; keep the historical
|
||||
|
|
@ -1389,11 +1394,12 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
return offline();
|
||||
});
|
||||
|
||||
if (verify) {
|
||||
if (verify === true) {
|
||||
// Online verification: a real connection probe settles states the
|
||||
// offline view cannot distinguish (revoked grant, dead refresh token).
|
||||
return probe();
|
||||
}
|
||||
if (verify === false) return offline();
|
||||
if (tokens.hasTokens) return offline();
|
||||
if (server.auth === 'oauth') return 'oauth-required';
|
||||
// Unpinned auth with no stored grant: probe once to detect whether the
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ const mcpServerLocatorSchema = z.discriminatedUnion('source', [
|
|||
|
||||
const inspectServersBodySchema = z.object({
|
||||
targets: z.array(mcpServerLocatorSchema).optional(),
|
||||
cwd: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const authCompleteBodySchema = z.object({
|
||||
|
|
@ -176,6 +177,16 @@ const namedServerErrorSchemas = {
|
|||
[ErrorCode.MCP_SERVER_NOT_FOUND]: {},
|
||||
};
|
||||
|
||||
const oauthErrorSchemas = {
|
||||
...baseErrorSchemas,
|
||||
[ErrorCode.MCP_OAUTH_FAILED]: {},
|
||||
};
|
||||
|
||||
const namedServerOAuthErrorSchemas = {
|
||||
...namedServerErrorSchemas,
|
||||
[ErrorCode.MCP_OAUTH_FAILED]: {},
|
||||
};
|
||||
|
||||
function sendMappedError(
|
||||
reply: { send(payload: unknown): unknown },
|
||||
requestId: string,
|
||||
|
|
@ -372,12 +383,14 @@ export function registerV2McpRoutes(app: V2McpRouteHost, core: Scope): void {
|
|||
success: { data: z.array(mcpServerInspectionSchema) },
|
||||
errors: namedServerErrorSchemas,
|
||||
description:
|
||||
'The locator-addressed catalog (redacted configs) plus a batched real-connection probe of every OAuth candidate. `targets` narrows the catalog; omitted inspects all.',
|
||||
'The locator-addressed catalog (redacted configs) plus a batched real-connection probe of every OAuth candidate. `targets` narrows the catalog; omitted inspects all. `cwd` includes trusted project layers.',
|
||||
tags: ['v2-mcp'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const inspections = await management().inspectServers(req.body.targets);
|
||||
const inspections = await management().inspectServers(req.body.targets, {
|
||||
cwd: req.body.cwd,
|
||||
});
|
||||
reply.send(okEnvelope(inspections, req.id));
|
||||
} catch (err) {
|
||||
sendMappedError(reply, req.id, err);
|
||||
|
|
@ -398,7 +411,7 @@ export function registerV2McpRoutes(app: V2McpRouteHost, core: Scope): void {
|
|||
success: { data: z.array(mcpServerAuthStatusSchema) },
|
||||
errors: baseErrorSchemas,
|
||||
description:
|
||||
'Per-server OAuth state over the registry catalog. Offline classification by default; `?verify=true` probes a real connection. Never mutates credentials.',
|
||||
'Per-server OAuth state over the registry catalog. Omitted `verify` preserves implicit OAuth detection; `verify=false` is fully offline; `verify=true` verifies every candidate. Probes may refresh or invalidate credentials.',
|
||||
tags: ['v2-mcp'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
|
|
@ -424,15 +437,16 @@ export function registerV2McpRoutes(app: V2McpRouteHost, core: Scope): void {
|
|||
method: 'POST',
|
||||
path: '/mcp/auth::begin',
|
||||
body: mcpServerLocatorSchema,
|
||||
querystring: serverScopedQuerySchema,
|
||||
success: { data: mcpServerAuthBeginResultSchema },
|
||||
errors: namedServerErrorSchemas,
|
||||
errors: namedServerOAuthErrorSchemas,
|
||||
description:
|
||||
'Begin an interactive OAuth flow for a remote server. Answers `authorization-required` with the flow handle + URL, or `already-authorized` when a grant exists.',
|
||||
tags: ['v2-mcp'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const result = await management().beginServerAuth(req.body);
|
||||
const result = await management().beginServerAuth(req.body, { cwd: req.query.cwd });
|
||||
reply.send(okEnvelope(result, req.id));
|
||||
} catch (err) {
|
||||
sendMappedError(reply, req.id, err);
|
||||
|
|
@ -451,7 +465,7 @@ export function registerV2McpRoutes(app: V2McpRouteHost, core: Scope): void {
|
|||
path: '/mcp/auth::complete',
|
||||
body: authCompleteBodySchema,
|
||||
success: { data: z.null() },
|
||||
errors: baseErrorSchemas,
|
||||
errors: oauthErrorSchemas,
|
||||
description:
|
||||
'Await the browser callback of a begun flow and finish the code exchange (`40001` for an unknown `flowId`).',
|
||||
tags: ['v2-mcp'],
|
||||
|
|
@ -486,7 +500,7 @@ export function registerV2McpRoutes(app: V2McpRouteHost, core: Scope): void {
|
|||
path: '/mcp/auth::cancel',
|
||||
body: authCancelBodySchema,
|
||||
success: { data: z.null() },
|
||||
errors: baseErrorSchemas,
|
||||
errors: oauthErrorSchemas,
|
||||
description: 'Tear down a begun OAuth flow without finishing it; unknown flows are ignored.',
|
||||
tags: ['v2-mcp'],
|
||||
},
|
||||
|
|
@ -510,15 +524,16 @@ export function registerV2McpRoutes(app: V2McpRouteHost, core: Scope): void {
|
|||
method: 'POST',
|
||||
path: '/mcp/auth::reset',
|
||||
body: mcpServerLocatorSchema,
|
||||
querystring: serverScopedQuerySchema,
|
||||
success: { data: z.null() },
|
||||
errors: namedServerErrorSchemas,
|
||||
errors: namedServerOAuthErrorSchemas,
|
||||
description:
|
||||
'Clear the stored credentials of one server; the invalidation event reaches live sessions.',
|
||||
tags: ['v2-mcp'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await management().resetServerAuth(req.body);
|
||||
await management().resetServerAuth(req.body, { cwd: req.query.cwd });
|
||||
reply.send(okEnvelope(null, req.id));
|
||||
} catch (err) {
|
||||
sendMappedError(reply, req.id, err);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,25 @@ describe('server-v2 OpenAPI', () => {
|
|||
const schema = asRecord(json['schema']);
|
||||
expect(Array.isArray(schema['oneOf'])).toBe(true);
|
||||
});
|
||||
|
||||
it('documents MCP OAuth failures for auth completion', async () => {
|
||||
const doc = await fetchOpenApi();
|
||||
const authCompleteOp = operation(doc, '/api/v2/mcp/auth:complete', 'post');
|
||||
const responses = asRecord(authCompleteOp['responses']);
|
||||
const response = asRecord(responses['200']);
|
||||
const content = asRecord(response['content']);
|
||||
const schema = asRecord(asRecord(content['application/json'])['schema']);
|
||||
const variants = schema['oneOf'];
|
||||
|
||||
expect(Array.isArray(variants)).toBe(true);
|
||||
expect(
|
||||
(variants as unknown[]).some((variant) => {
|
||||
const properties = asRecord(asRecord(variant)['properties']);
|
||||
const values = asRecord(properties['code'])['enum'];
|
||||
return Array.isArray(values) && values.includes(40929);
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ interface McpStub {
|
|||
lastUpdate?: GlobalMcpServerConfig;
|
||||
lastTestTarget?: McpServerTestTarget;
|
||||
lastResetLocator?: McpServerLocator;
|
||||
lastInspectCwd?: string;
|
||||
lastBeginCwd?: string;
|
||||
lastResetCwd?: string;
|
||||
verifySeen?: boolean;
|
||||
mutationCwds: Array<string | undefined>;
|
||||
};
|
||||
|
|
@ -119,8 +122,9 @@ function makeMcpStub(): McpStub {
|
|||
authStatus: 'not-applicable' as const,
|
||||
}));
|
||||
},
|
||||
inspectServers: async (targets) => {
|
||||
inspectServers: async (targets, query) => {
|
||||
calls.push('inspectServers');
|
||||
state.lastInspectCwd = query?.cwd;
|
||||
const selected = [...servers.values()].filter(
|
||||
(server) =>
|
||||
targets === undefined ||
|
||||
|
|
@ -142,19 +146,23 @@ function makeMcpStub(): McpStub {
|
|||
});
|
||||
},
|
||||
resolveServerByName: async (name) => ({ source: 'global', name }),
|
||||
beginServerAuth: async () => ({
|
||||
status: 'authorization-required',
|
||||
flowId: 'flow-1',
|
||||
authorizationUrl: 'https://example.com/oauth/authorize?client=x',
|
||||
}),
|
||||
beginServerAuth: async (_locator, query) => {
|
||||
state.lastBeginCwd = query?.cwd;
|
||||
return {
|
||||
status: 'authorization-required',
|
||||
flowId: 'flow-1',
|
||||
authorizationUrl: 'https://example.com/oauth/authorize?client=x',
|
||||
};
|
||||
},
|
||||
completeServerAuth: async (handle) => {
|
||||
if (handle.flowId !== 'flow-1') {
|
||||
throw new Error2(ErrorCodes.REQUEST_INVALID, `Unknown MCP OAuth flow: ${handle.flowId}`);
|
||||
}
|
||||
},
|
||||
cancelServerAuth: async () => {},
|
||||
resetServerAuth: async (locator) => {
|
||||
resetServerAuth: async (locator, query) => {
|
||||
state.lastResetLocator = locator;
|
||||
state.lastResetCwd = query?.cwd;
|
||||
},
|
||||
};
|
||||
return { service, calls, state };
|
||||
|
|
@ -384,6 +392,30 @@ describe('server /api/v2/mcp', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('forwards cwd through locator-addressed inspection and OAuth operations', async () => {
|
||||
const stub = makeMcpStub();
|
||||
await boot(stub);
|
||||
|
||||
await call('POST', '/api/v2/mcp/servers:inspect', {
|
||||
targets: [],
|
||||
cwd: '/workspace/project',
|
||||
});
|
||||
await call('POST', '/api/v2/mcp/auth:begin?cwd=%2Fworkspace%2Fproject', {
|
||||
source: 'global',
|
||||
name: 'a',
|
||||
});
|
||||
await call('POST', '/api/v2/mcp/auth:reset?cwd=%2Fworkspace%2Fproject', {
|
||||
source: 'global',
|
||||
name: 'a',
|
||||
});
|
||||
|
||||
expect(stub.state).toMatchObject({
|
||||
lastInspectCwd: '/workspace/project',
|
||||
lastBeginCwd: '/workspace/project',
|
||||
lastResetCwd: '/workspace/project',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps ?verify= onto the boolean auth-status query flag', async () => {
|
||||
const stub = makeMcpStub();
|
||||
await boot(stub);
|
||||
|
|
|
|||
|
|
@ -158,15 +158,18 @@ export const mcpManagementContract = {
|
|||
output: z.array(mcpServerAuthStatusSchema),
|
||||
},
|
||||
inspectServers: {
|
||||
input: z.tuple([z.array(mcpServerLocatorSchema).optional()]),
|
||||
input: z.tuple([
|
||||
z.array(mcpServerLocatorSchema).optional(),
|
||||
mcpRegistryQuerySchema.optional(),
|
||||
]),
|
||||
output: z.array(mcpServerInspectionSchema),
|
||||
},
|
||||
resolveServerByName: {
|
||||
input: z.tuple([z.string().min(1)]),
|
||||
input: z.tuple([z.string().min(1), mcpRegistryQuerySchema.optional()]),
|
||||
output: mcpServerLocatorSchema,
|
||||
},
|
||||
beginServerAuth: {
|
||||
input: z.tuple([mcpServerLocatorSchema]),
|
||||
input: z.tuple([mcpServerLocatorSchema, mcpRegistryQuerySchema.optional()]),
|
||||
output: mcpServerAuthBeginResultSchema,
|
||||
},
|
||||
completeServerAuth: {
|
||||
|
|
@ -178,7 +181,7 @@ export const mcpManagementContract = {
|
|||
output: noResult,
|
||||
},
|
||||
resetServerAuth: {
|
||||
input: z.tuple([mcpServerLocatorSchema]),
|
||||
input: z.tuple([mcpServerLocatorSchema, mcpRegistryQuerySchema.optional()]),
|
||||
output: noResult,
|
||||
},
|
||||
} satisfies ServiceContract;
|
||||
|
|
|
|||
|
|
@ -258,18 +258,22 @@ export interface GlobalMcpFacade {
|
|||
/** The locator-addressed catalog plus a batched real-connection probe of OAuth candidates. */
|
||||
inspect(input?: {
|
||||
targets?: readonly McpServerLocator[];
|
||||
cwd?: string;
|
||||
}): Promise<readonly McpServerInspection[]>;
|
||||
/** Per-server OAuth state; offline by default, `verify: true` probes a real connection. */
|
||||
/** Per-server OAuth state; omitted `verify` detects implicit OAuth, `false` stays offline. */
|
||||
authStatuses(input?: {
|
||||
cwd?: string;
|
||||
verify?: boolean;
|
||||
}): Promise<readonly McpServerAuthStatus[]>;
|
||||
/** Resolve a legacy name-only auth target to its unambiguous locator. */
|
||||
resolveByName(input: { name: string }): Promise<McpServerLocator>;
|
||||
beginAuth(input: { locator: McpServerLocator }): Promise<McpServerAuthBeginResult>;
|
||||
resolveByName(input: { name: string; cwd?: string }): Promise<McpServerLocator>;
|
||||
beginAuth(input: {
|
||||
locator: McpServerLocator;
|
||||
cwd?: string;
|
||||
}): Promise<McpServerAuthBeginResult>;
|
||||
completeAuth(input: { flowId: string; timeoutMs?: number }): Promise<void>;
|
||||
cancelAuth(input: { flowId: string }): Promise<void>;
|
||||
resetAuth(input: { locator: McpServerLocator }): Promise<void>;
|
||||
resetAuth(input: { locator: McpServerLocator; cwd?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
/** One downloaded upload: its metadata plus the buffered bytes. */
|
||||
|
|
@ -593,23 +597,31 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr
|
|||
test: (target) =>
|
||||
call('mcpManagementService', 'testServer', [target]) as Promise<McpServerTestResult>,
|
||||
inspect: (input) =>
|
||||
call('mcpManagementService', 'inspectServers', [input?.targets]) as Promise<
|
||||
call('mcpManagementService', 'inspectServers', [
|
||||
input?.targets,
|
||||
input === undefined ? undefined : { cwd: input.cwd },
|
||||
]) as Promise<
|
||||
readonly McpServerInspection[]
|
||||
>,
|
||||
authStatuses: (input) =>
|
||||
call('mcpManagementService', 'listAuthStatuses', [
|
||||
input === undefined ? undefined : { cwd: input.cwd, verify: input.verify },
|
||||
]) as Promise<readonly McpServerAuthStatus[]>,
|
||||
resolveByName: ({ name }) =>
|
||||
call('mcpManagementService', 'resolveServerByName', [name]) as Promise<McpServerLocator>,
|
||||
beginAuth: ({ locator }) =>
|
||||
call('mcpManagementService', 'beginServerAuth', [locator]) as Promise<McpServerAuthBeginResult>,
|
||||
resolveByName: ({ name, cwd }) =>
|
||||
call('mcpManagementService', 'resolveServerByName', [name, { cwd }]) as Promise<
|
||||
McpServerLocator
|
||||
>,
|
||||
beginAuth: ({ locator, cwd }) =>
|
||||
call('mcpManagementService', 'beginServerAuth', [
|
||||
locator,
|
||||
{ cwd },
|
||||
]) as Promise<McpServerAuthBeginResult>,
|
||||
completeAuth: ({ flowId, timeoutMs }) =>
|
||||
call('mcpManagementService', 'completeServerAuth', [{ flowId, timeoutMs }]) as Promise<void>,
|
||||
cancelAuth: ({ flowId }) =>
|
||||
call('mcpManagementService', 'cancelServerAuth', [{ flowId }]) as Promise<void>,
|
||||
resetAuth: ({ locator }) =>
|
||||
call('mcpManagementService', 'resetServerAuth', [locator]) as Promise<void>,
|
||||
resetAuth: ({ locator, cwd }) =>
|
||||
call('mcpManagementService', 'resetServerAuth', [locator, { cwd }]) as Promise<void>,
|
||||
},
|
||||
|
||||
env,
|
||||
|
|
|
|||
|
|
@ -501,8 +501,9 @@ export class KimiHarness {
|
|||
*/
|
||||
async inspectAppMcpServers(
|
||||
targets?: readonly McpServerLocator[],
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<readonly AppMcpServerInspection[]> {
|
||||
return this.rpc.inspectAppMcpServers(targets);
|
||||
return this.rpc.inspectAppMcpServers(targets, options);
|
||||
}
|
||||
|
||||
async addMcpServer(
|
||||
|
|
@ -530,7 +531,7 @@ export class KimiHarness {
|
|||
name: string,
|
||||
options: AuthenticateMcpServerOptions,
|
||||
): Promise<void> {
|
||||
const started = await this.rpc.beginGlobalMcpServerAuth(name);
|
||||
const started = await this.rpc.beginGlobalMcpServerAuth(name, { cwd: options.cwd });
|
||||
if (started.status === 'already-authorized') return;
|
||||
try {
|
||||
const opened = await options.onAuthorizationUrl(started.authorizationUrl);
|
||||
|
|
@ -547,8 +548,11 @@ export class KimiHarness {
|
|||
}
|
||||
}
|
||||
|
||||
async resetMcpServerAuth(name: string): Promise<void> {
|
||||
return this.rpc.resetGlobalMcpServerAuth(name);
|
||||
async resetMcpServerAuth(
|
||||
name: string,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<void> {
|
||||
return this.rpc.resetGlobalMcpServerAuth(name, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -560,7 +564,7 @@ export class KimiHarness {
|
|||
locator: McpServerLocator,
|
||||
options: AuthenticateMcpServerOptions,
|
||||
): Promise<void> {
|
||||
const started = await this.rpc.beginMcpServerAuth(locator);
|
||||
const started = await this.rpc.beginMcpServerAuth(locator, { cwd: options.cwd });
|
||||
if (started.status === 'already-authorized') return;
|
||||
try {
|
||||
const opened = await options.onAuthorizationUrl(started.authorizationUrl);
|
||||
|
|
@ -578,8 +582,11 @@ export class KimiHarness {
|
|||
}
|
||||
|
||||
/** The locator-addressed variant of {@link resetMcpServerAuth}. */
|
||||
async resetAppMcpServerAuth(locator: McpServerLocator): Promise<void> {
|
||||
return this.rpc.resetMcpServerAuth(locator);
|
||||
async resetAppMcpServerAuth(
|
||||
locator: McpServerLocator,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<void> {
|
||||
return this.rpc.resetMcpServerAuth(locator, options);
|
||||
}
|
||||
|
||||
async testMcpServer(
|
||||
|
|
|
|||
|
|
@ -409,9 +409,10 @@ export abstract class SDKRpcClientBase {
|
|||
|
||||
async inspectAppMcpServers(
|
||||
targets?: readonly McpServerLocator[],
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<readonly AppMcpServerInspection[]> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.inspectAppMcpServers({ targets });
|
||||
return rpc.inspectAppMcpServers({ targets, cwd: options.cwd });
|
||||
}
|
||||
|
||||
async addGlobalMcpServer(
|
||||
|
|
@ -438,14 +439,20 @@ export abstract class SDKRpcClientBase {
|
|||
return rpc.removeGlobalMcpServer({ name, cwd: options.cwd });
|
||||
}
|
||||
|
||||
async beginGlobalMcpServerAuth(name: string): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
async beginGlobalMcpServerAuth(
|
||||
name: string,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.beginGlobalMcpServerAuth({ name });
|
||||
return rpc.beginGlobalMcpServerAuth({ name, cwd: options.cwd });
|
||||
}
|
||||
|
||||
async beginMcpServerAuth(locator: McpServerLocator): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
async beginMcpServerAuth(
|
||||
locator: McpServerLocator,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.beginMcpServerAuth({ locator });
|
||||
return rpc.beginMcpServerAuth({ locator, cwd: options.cwd });
|
||||
}
|
||||
|
||||
async completeGlobalMcpServerAuth(
|
||||
|
|
@ -474,14 +481,20 @@ export abstract class SDKRpcClientBase {
|
|||
return rpc.cancelMcpServerAuth({ flowId });
|
||||
}
|
||||
|
||||
async resetGlobalMcpServerAuth(name: string): Promise<void> {
|
||||
async resetGlobalMcpServerAuth(
|
||||
name: string,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<void> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.resetGlobalMcpServerAuth({ name });
|
||||
return rpc.resetGlobalMcpServerAuth({ name, cwd: options.cwd });
|
||||
}
|
||||
|
||||
async resetMcpServerAuth(locator: McpServerLocator): Promise<void> {
|
||||
async resetMcpServerAuth(
|
||||
locator: McpServerLocator,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<void> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.resetMcpServerAuth({ locator });
|
||||
return rpc.resetMcpServerAuth({ locator, cwd: options.cwd });
|
||||
}
|
||||
|
||||
async testGlobalMcpServer(
|
||||
|
|
|
|||
|
|
@ -2321,10 +2321,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
|
||||
override async inspectAppMcpServers(
|
||||
targets?: readonly McpServerLocator[],
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<readonly AppMcpServerInspection[]> {
|
||||
const inspections = await this.engineAccessor
|
||||
.get(IMcpManagementService)
|
||||
.inspectServers(targets);
|
||||
.inspectServers(targets, { cwd: options.cwd });
|
||||
// Field-identical with the v1 wire shape (the engines' locator /
|
||||
// config-view / auth-state declarations match structurally).
|
||||
return inspections as readonly AppMcpServerInspection[];
|
||||
|
|
@ -2365,15 +2366,22 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
* enabled entry may own the runtime name, so a global/plugin collision
|
||||
* rejects instead of guessing which credential the flow acts on.
|
||||
*/
|
||||
override async beginGlobalMcpServerAuth(name: string): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
override async beginGlobalMcpServerAuth(
|
||||
name: string,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
const management = this.engineAccessor.get(IMcpManagementService);
|
||||
return management.beginServerAuth(await management.resolveServerByName(name));
|
||||
const query = { cwd: options.cwd };
|
||||
return management.beginServerAuth(await management.resolveServerByName(name, query), query);
|
||||
}
|
||||
|
||||
override async beginMcpServerAuth(
|
||||
locator: McpServerLocator,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<BeginGlobalMcpServerAuthResult> {
|
||||
return this.engineAccessor.get(IMcpManagementService).beginServerAuth(locator);
|
||||
return this.engineAccessor
|
||||
.get(IMcpManagementService)
|
||||
.beginServerAuth(locator, { cwd: options.cwd });
|
||||
}
|
||||
|
||||
override async completeGlobalMcpServerAuth(
|
||||
|
|
@ -2406,13 +2414,22 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
return this.engineAccessor.get(IMcpManagementService).cancelServerAuth({ flowId });
|
||||
}
|
||||
|
||||
override async resetGlobalMcpServerAuth(name: string): Promise<void> {
|
||||
override async resetGlobalMcpServerAuth(
|
||||
name: string,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<void> {
|
||||
const management = this.engineAccessor.get(IMcpManagementService);
|
||||
return management.resetServerAuth(await management.resolveServerByName(name));
|
||||
const query = { cwd: options.cwd };
|
||||
return management.resetServerAuth(await management.resolveServerByName(name, query), query);
|
||||
}
|
||||
|
||||
override async resetMcpServerAuth(locator: McpServerLocator): Promise<void> {
|
||||
return this.engineAccessor.get(IMcpManagementService).resetServerAuth(locator);
|
||||
override async resetMcpServerAuth(
|
||||
locator: McpServerLocator,
|
||||
options: { readonly cwd?: string } = {},
|
||||
): Promise<void> {
|
||||
return this.engineAccessor
|
||||
.get(IMcpManagementService)
|
||||
.resetServerAuth(locator, { cwd: options.cwd });
|
||||
}
|
||||
|
||||
override async testGlobalMcpServer(
|
||||
|
|
|
|||
|
|
@ -289,6 +289,7 @@ export interface AuthenticateMcpServerOptions {
|
|||
) => void | boolean | PromiseLike<void | boolean>;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
readonly cwd?: string;
|
||||
}
|
||||
|
||||
export interface TestMcpServerOptions {
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('reports global MCP authorization from the persisted v2 credential store without probing', async () => {
|
||||
it('reports global MCP authorization without probing when verify is false', async () => {
|
||||
const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-'));
|
||||
tempDirs.push(homeDir);
|
||||
const implicitOAuthUrl = 'https://implicit-oauth.example.test/mcp';
|
||||
|
|
@ -175,7 +175,7 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring)', () => {
|
|||
const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY });
|
||||
|
||||
try {
|
||||
await expect(harness.listMcpServerAuthStatuses()).resolves.toEqual([
|
||||
await expect(harness.listMcpServerAuthStatuses({ verify: false })).resolves.toEqual([
|
||||
{ name: 'stdio', authStatus: 'not-applicable' },
|
||||
{ name: 'plain', authStatus: 'not-applicable' },
|
||||
{ name: 'detected', authStatus: 'not-applicable' },
|
||||
|
|
@ -191,7 +191,7 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring)', () => {
|
|||
.saveTokens({ access_token: 'new-test-access-token', token_type: 'Bearer' });
|
||||
await externalOAuth.invalidate('oauth-authorized', authorizedUrl, 'tokens');
|
||||
|
||||
await expect(harness.listMcpServerAuthStatuses()).resolves.toEqual([
|
||||
await expect(harness.listMcpServerAuthStatuses({ verify: false })).resolves.toEqual([
|
||||
{ name: 'stdio', authStatus: 'not-applicable' },
|
||||
{ name: 'plain', authStatus: 'not-applicable' },
|
||||
{ name: 'detected', authStatus: 'not-applicable' },
|
||||
|
|
|
|||
|
|
@ -3750,7 +3750,7 @@ function expectSameManagedServers(
|
|||
}
|
||||
|
||||
describe('v1↔v2 global MCP parity', () => {
|
||||
it('keeps v1 implicit detection while v2 classifies persisted credentials offline', async () => {
|
||||
it('detects implicit OAuth requirements identically by default', async () => {
|
||||
const statusServer = await startMcpAuthStatusServer();
|
||||
const authorizedUrl = 'https://authorized.example.test/mcp';
|
||||
const pair = await makeGlobalMcpParityPair({
|
||||
|
|
@ -3809,17 +3809,7 @@ describe('v1↔v2 global MCP parity', () => {
|
|||
{ name: 'oauth-authorized', authStatus: 'oauth-authorized' },
|
||||
{ name: 'disabled-oauth', authStatus: 'not-applicable' },
|
||||
]);
|
||||
expect(v2Statuses).toEqual([
|
||||
{ name: 'stdio', authStatus: 'not-applicable' },
|
||||
{ name: 'plain', authStatus: 'not-applicable' },
|
||||
{ name: 'detected', authStatus: 'not-applicable' },
|
||||
{ name: 'sse', authStatus: 'not-applicable' },
|
||||
{ name: 'sse-oauth', authStatus: 'oauth-required' },
|
||||
{ name: 'bearer', authStatus: 'bearer-token' },
|
||||
{ name: 'oauth-required', authStatus: 'oauth-required' },
|
||||
{ name: 'oauth-authorized', authStatus: 'oauth-authorized' },
|
||||
{ name: 'disabled-oauth', authStatus: 'not-applicable' },
|
||||
]);
|
||||
expect(v2Statuses).toEqual(v1Statuses);
|
||||
} finally {
|
||||
await closeGlobalMcpPair(pair);
|
||||
await statusServer.close();
|
||||
|
|
@ -3910,9 +3900,8 @@ describe('v1↔v2 global MCP parity', () => {
|
|||
{ name: 'oauth-required', authStatus: 'oauth-required' },
|
||||
]);
|
||||
|
||||
// The v2 name-based list stays fully offline unless verify is requested:
|
||||
// stored grants are classified from disk, and unpinned HTTP servers are
|
||||
// not contacted. V1 retains its implicit no-grant detection for compatibility.
|
||||
// The name-based list preserves implicit no-grant detection when verify
|
||||
// is omitted, while stored grants are classified from disk.
|
||||
const [v1LegacyStatuses, v2LegacyStatuses] = await Promise.all([
|
||||
pair.v1.listGlobalMcpServerAuthStatuses(),
|
||||
pair.v2.listGlobalMcpServerAuthStatuses(),
|
||||
|
|
@ -3928,17 +3917,7 @@ describe('v1↔v2 global MCP parity', () => {
|
|||
{ name: 'unavailable-explicit', authStatus: 'oauth-required' },
|
||||
{ name: 'unavailable-dynamic', authStatus: 'not-applicable' },
|
||||
]);
|
||||
expect(v2LegacyStatuses).toEqual([
|
||||
{ name: 'stdio', authStatus: 'not-applicable' },
|
||||
{ name: 'plain', authStatus: 'not-applicable' },
|
||||
{ name: 'detected', authStatus: 'not-applicable' },
|
||||
{ name: 'bearer', authStatus: 'bearer-token' },
|
||||
{ name: 'oauth-required', authStatus: 'oauth-required' },
|
||||
{ name: 'oauth-authorized', authStatus: 'oauth-authorized' },
|
||||
{ name: 'oauth-stale', authStatus: 'oauth-authorized' },
|
||||
{ name: 'unavailable-explicit', authStatus: 'oauth-required' },
|
||||
{ name: 'unavailable-dynamic', authStatus: 'not-applicable' },
|
||||
]);
|
||||
expect(v2LegacyStatuses).toEqual(v1LegacyStatuses);
|
||||
} finally {
|
||||
await closeGlobalMcpPair(pair);
|
||||
await statusServer.close();
|
||||
|
|
@ -4220,6 +4199,56 @@ describe('v1↔v2 global MCP parity', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('threads cwd through project-layer inspection and authorization on both engines', async () => {
|
||||
const pair = await makeGlobalMcpParityPair();
|
||||
const project = await makeTempDir('kimi-sdk-parity-mcp-auth-project-');
|
||||
await mkdir(join(project, '.kimi-code'), { recursive: true });
|
||||
await writeFile(
|
||||
join(project, '.kimi-code', 'mcp.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
'project-stdio': { command: 'project-command' },
|
||||
'project-oauth': {
|
||||
transport: 'http',
|
||||
url: 'https://project.example.test/mcp',
|
||||
auth: 'oauth',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
try {
|
||||
await pair.v2.trustWorkspace(project);
|
||||
|
||||
const [v1Inspections, v2Inspections] = await Promise.all([
|
||||
pair.v1.inspectAppMcpServers([{ source: 'global', name: 'project-stdio' }], {
|
||||
cwd: project,
|
||||
}),
|
||||
pair.v2.inspectAppMcpServers([{ source: 'global', name: 'project-stdio' }], {
|
||||
cwd: project,
|
||||
}),
|
||||
]);
|
||||
const summarize = (inspections: typeof v1Inspections) =>
|
||||
inspections.map(({ runtimeName, authStatus }) => ({ runtimeName, authStatus }));
|
||||
expect(summarize(v2Inspections)).toEqual(summarize(v1Inspections));
|
||||
expect(summarize(v1Inspections)).toEqual([
|
||||
{ runtimeName: 'project-stdio', authStatus: 'not-applicable' },
|
||||
]);
|
||||
|
||||
await expectSameMcpRejection(
|
||||
pair,
|
||||
(client) => client.beginGlobalMcpServerAuth('project-stdio', { cwd: project }),
|
||||
(client) => client.beginGlobalMcpServerAuth('project-stdio', { cwd: project }),
|
||||
);
|
||||
await Promise.all([
|
||||
pair.v1.resetGlobalMcpServerAuth('project-oauth', { cwd: project }),
|
||||
pair.v2.resetGlobalMcpServerAuth('project-oauth', { cwd: project }),
|
||||
]);
|
||||
} finally {
|
||||
await closeGlobalMcpPair(pair);
|
||||
}
|
||||
});
|
||||
|
||||
it('a malformed mcp.json rejects every read with the same config.invalid', async () => {
|
||||
const pair = await makeGlobalMcpParityPair();
|
||||
await writeFile(join(pair.v1HomeDir, 'mcp.json'), '{ not valid json', 'utf-8');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue