diff --git a/.changeset/sdk-mcp-management-cwd.md b/.changeset/sdk-mcp-management-cwd.md index 8fa2ad5d3..51acfc3b5 100644 --- a/.changeset/sdk-mcp-management-cwd.md +++ b/.changeset/sdk-mcp-management-cwd.md @@ -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. diff --git a/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts b/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts index 3eb35a17a..dbded9e44 100644 --- a/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts +++ b/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts @@ -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; @@ -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; + inspectServers( + targets?: readonly McpServerLocator[], + query?: McpRegistryQuery, + ): Promise; /** * 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; + resolveServerByName(name: string, query?: McpRegistryQuery): Promise; /** Begin an interactive OAuth flow for a remote server. */ - beginServerAuth(locator: McpServerLocator): Promise; + beginServerAuth( + locator: McpServerLocator, + query?: McpRegistryQuery, + ): Promise; /** Await the browser callback and finish the code exchange. Unknown flow → request.invalid. */ completeServerAuth( @@ -158,7 +168,7 @@ export interface IMcpManagementService { cancelServerAuth(handle: Pick): Promise; /** Clear stored credentials; the invalidation event reaches live sessions. */ - resetServerAuth(locator: McpServerLocator): Promise; + resetServerAuth(locator: McpServerLocator, query?: McpRegistryQuery): Promise; } export const IMcpManagementService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts b/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts index fbb53ff0f..22b107e88 100644 --- a/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts +++ b/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts @@ -235,20 +235,20 @@ export class McpManagementService extends Disposable implements IMcpManagementSe async listAuthStatuses(query: McpAuthStatusQuery = {}): Promise { 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 { 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 { - await this.registry.get(name); - const catalog = await this.serverDescriptors(); + async resolveServerByName(name: string, query: McpRegistryQuery = {}): Promise { + 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 { + async beginServerAuth( + locator: McpServerLocator, + query: McpRegistryQuery = {}, + ): Promise { 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 { + async resetServerAuth(locator: McpServerLocator, query: McpRegistryQuery = {}): Promise { 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 { - return (await this.registry.list()).map((entry) => serverDescriptor(entry)); + private async serverDescriptors( + query: McpRegistryQuery = {}, + ): Promise { + return (await this.registry.list(query)).map((entry) => serverDescriptor(entry)); } private async resolveServer( locator: McpServerLocator, + query: McpRegistryQuery, ): Promise { - 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 { 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( diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts index fb10f0d0b..1103949a4 100644 --- a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts @@ -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((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; } diff --git a/packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts b/packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts index 0be66c9aa..dbfb8a822 100644 --- a/packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts +++ b/packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts @@ -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({ diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts index c4736884f..0332dc162 100644 --- a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts @@ -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(); diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 09ce6b38b..787eef6b3 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -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; } diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 2db2d18c8..34c505f70 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -855,11 +855,10 @@ export class KimiCore implements PromisableMethods { ): Promise { 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 { } async beginGlobalMcpServerAuth( - { name }: GlobalMcpServerNamePayload, + { name, cwd }: GlobalMcpServerNamePayload, ): Promise { - return this.beginAppMcpServerAuth(await this.resolveLegacyNamedAppMcpServer(name)); + return this.beginAppMcpServerAuth(await this.resolveLegacyNamedAppMcpServer(name, cwd)); } async beginMcpServerAuth({ locator, + cwd, }: McpServerLocatorPayload): Promise { - 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 { await active.flow.cancel(); } - async resetGlobalMcpServerAuth({ name }: GlobalMcpServerNamePayload): Promise { + async resetGlobalMcpServerAuth({ name, cwd }: GlobalMcpServerNamePayload): Promise { // 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 { - await this.appMcpServerDescriptorReset(await this.resolveAppMcpServer(locator)); + async resetMcpServerAuth({ locator, cwd }: McpServerLocatorPayload): Promise { + await this.appMcpServerDescriptorReset(await this.resolveAppMcpServer(locator, cwd)); } private async appMcpServerDescriptorReset( @@ -1107,17 +1107,20 @@ export class KimiCore implements PromisableMethods { async inspectAppMcpServers({ targets, + cwd, }: InspectAppMcpServersPayload): Promise { - 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 { + private async appMcpServerDescriptors( + cwd?: string, + ): Promise { 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 { private async resolveAppMcpServer( locator: McpServerLocator, + cwd?: string, ): Promise { - 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 { */ private async resolveLegacyNamedAppMcpServer( name: string, + cwd?: string, ): Promise { 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 { private async mcpServerAuthState( entry: McpRegistryEntry, cwd: string | undefined, - verify: boolean, + verify: boolean | undefined, ): Promise { const server = entry.config; // A disabled server never participates in OAuth; keep the historical @@ -1389,11 +1394,12 @@ export class KimiCore implements PromisableMethods { 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 diff --git a/packages/kap-server/src/routes/v2/mcp.ts b/packages/kap-server/src/routes/v2/mcp.ts index 92e946154..9976e774c 100644 --- a/packages/kap-server/src/routes/v2/mcp.ts +++ b/packages/kap-server/src/routes/v2/mcp.ts @@ -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); diff --git a/packages/kap-server/test/openapi.test.ts b/packages/kap-server/test/openapi.test.ts index 070223604..20c9a1338 100644 --- a/packages/kap-server/test/openapi.test.ts +++ b/packages/kap-server/test/openapi.test.ts @@ -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 { diff --git a/packages/kap-server/test/v2Mcp.test.ts b/packages/kap-server/test/v2Mcp.test.ts index 08802af84..afd989437 100644 --- a/packages/kap-server/test/v2Mcp.test.ts +++ b/packages/kap-server/test/v2Mcp.test.ts @@ -41,6 +41,9 @@ interface McpStub { lastUpdate?: GlobalMcpServerConfig; lastTestTarget?: McpServerTestTarget; lastResetLocator?: McpServerLocator; + lastInspectCwd?: string; + lastBeginCwd?: string; + lastResetCwd?: string; verifySeen?: boolean; mutationCwds: Array; }; @@ -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); diff --git a/packages/klient/src/contract/global/mcpManagement.ts b/packages/klient/src/contract/global/mcpManagement.ts index ed461823a..e3fe84dec 100644 --- a/packages/klient/src/contract/global/mcpManagement.ts +++ b/packages/klient/src/contract/global/mcpManagement.ts @@ -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; diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index 423702c06..f474b66bd 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -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; - /** 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; /** Resolve a legacy name-only auth target to its unambiguous locator. */ - resolveByName(input: { name: string }): Promise; - beginAuth(input: { locator: McpServerLocator }): Promise; + resolveByName(input: { name: string; cwd?: string }): Promise; + beginAuth(input: { + locator: McpServerLocator; + cwd?: string; + }): Promise; completeAuth(input: { flowId: string; timeoutMs?: number }): Promise; cancelAuth(input: { flowId: string }): Promise; - resetAuth(input: { locator: McpServerLocator }): Promise; + resetAuth(input: { locator: McpServerLocator; cwd?: string }): Promise; } /** 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, 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, - resolveByName: ({ name }) => - call('mcpManagementService', 'resolveServerByName', [name]) as Promise, - beginAuth: ({ locator }) => - call('mcpManagementService', 'beginServerAuth', [locator]) as Promise, + resolveByName: ({ name, cwd }) => + call('mcpManagementService', 'resolveServerByName', [name, { cwd }]) as Promise< + McpServerLocator + >, + beginAuth: ({ locator, cwd }) => + call('mcpManagementService', 'beginServerAuth', [ + locator, + { cwd }, + ]) as Promise, completeAuth: ({ flowId, timeoutMs }) => call('mcpManagementService', 'completeServerAuth', [{ flowId, timeoutMs }]) as Promise, cancelAuth: ({ flowId }) => call('mcpManagementService', 'cancelServerAuth', [{ flowId }]) as Promise, - resetAuth: ({ locator }) => - call('mcpManagementService', 'resetServerAuth', [locator]) as Promise, + resetAuth: ({ locator, cwd }) => + call('mcpManagementService', 'resetServerAuth', [locator, { cwd }]) as Promise, }, env, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index d9a1e7cb9..48b2e3c1e 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -501,8 +501,9 @@ export class KimiHarness { */ async inspectAppMcpServers( targets?: readonly McpServerLocator[], + options: { readonly cwd?: string } = {}, ): Promise { - 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 { - 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 { - return this.rpc.resetGlobalMcpServerAuth(name); + async resetMcpServerAuth( + name: string, + options: { readonly cwd?: string } = {}, + ): Promise { + return this.rpc.resetGlobalMcpServerAuth(name, options); } /** @@ -560,7 +564,7 @@ export class KimiHarness { locator: McpServerLocator, options: AuthenticateMcpServerOptions, ): Promise { - 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 { - return this.rpc.resetMcpServerAuth(locator); + async resetAppMcpServerAuth( + locator: McpServerLocator, + options: { readonly cwd?: string } = {}, + ): Promise { + return this.rpc.resetMcpServerAuth(locator, options); } async testMcpServer( diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 4073aa3e9..f5b96ef0f 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -409,9 +409,10 @@ export abstract class SDKRpcClientBase { async inspectAppMcpServers( targets?: readonly McpServerLocator[], + options: { readonly cwd?: string } = {}, ): Promise { 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 { + async beginGlobalMcpServerAuth( + name: string, + options: { readonly cwd?: string } = {}, + ): Promise { const rpc = await this.getRpc(); - return rpc.beginGlobalMcpServerAuth({ name }); + return rpc.beginGlobalMcpServerAuth({ name, cwd: options.cwd }); } - async beginMcpServerAuth(locator: McpServerLocator): Promise { + async beginMcpServerAuth( + locator: McpServerLocator, + options: { readonly cwd?: string } = {}, + ): Promise { 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 { + async resetGlobalMcpServerAuth( + name: string, + options: { readonly cwd?: string } = {}, + ): Promise { const rpc = await this.getRpc(); - return rpc.resetGlobalMcpServerAuth({ name }); + return rpc.resetGlobalMcpServerAuth({ name, cwd: options.cwd }); } - async resetMcpServerAuth(locator: McpServerLocator): Promise { + async resetMcpServerAuth( + locator: McpServerLocator, + options: { readonly cwd?: string } = {}, + ): Promise { const rpc = await this.getRpc(); - return rpc.resetMcpServerAuth({ locator }); + return rpc.resetMcpServerAuth({ locator, cwd: options.cwd }); } async testGlobalMcpServer( diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index f2385f95c..5b45204ae 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -2321,10 +2321,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { override async inspectAppMcpServers( targets?: readonly McpServerLocator[], + options: { readonly cwd?: string } = {}, ): Promise { 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 { + override async beginGlobalMcpServerAuth( + name: string, + options: { readonly cwd?: string } = {}, + ): Promise { 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 { - 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 { + override async resetGlobalMcpServerAuth( + name: string, + options: { readonly cwd?: string } = {}, + ): Promise { 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 { - return this.engineAccessor.get(IMcpManagementService).resetServerAuth(locator); + override async resetMcpServerAuth( + locator: McpServerLocator, + options: { readonly cwd?: string } = {}, + ): Promise { + return this.engineAccessor + .get(IMcpManagementService) + .resetServerAuth(locator, { cwd: options.cwd }); } override async testGlobalMcpServer( diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 7b474cae0..082f1d5d5 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -289,6 +289,7 @@ export interface AuthenticateMcpServerOptions { ) => void | boolean | PromiseLike; readonly signal?: AbortSignal; readonly timeoutMs?: number; + readonly cwd?: string; } export interface TestMcpServerOptions { 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 8449fdf14..5820e4c21 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -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' }, diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 9e9159c95..ae0197697 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -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');