fix(mcp): preserve auth management semantics

This commit is contained in:
7Sageer 2026-08-20 18:13:34 +08:00
parent 68d4e7ab14
commit 234c689bba
19 changed files with 476 additions and 135 deletions

View file

@ -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.

View file

@ -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> =

View file

@ -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(

View file

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

View file

@ -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({

View file

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

View file

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

View file

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

View file

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

View file

@ -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> {

View file

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

View file

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

View file

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

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -289,6 +289,7 @@ export interface AuthenticateMcpServerOptions {
) => void | boolean | PromiseLike<void | boolean>;
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
readonly cwd?: string;
}
export interface TestMcpServerOptions {

View file

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

View file

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