diff --git a/.changeset/mcp-oauth-authenticate-tool.md b/.changeset/mcp-oauth-authenticate-tool.md new file mode 100644 index 00000000000..12e3e7fe842 --- /dev/null +++ b/.changeset/mcp-oauth-authenticate-tool.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the missing OAuth authenticate tool for remote MCP servers that require login. diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts index f37c6530650..951088adbf6 100644 --- a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts @@ -156,24 +156,33 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ ready: Promise, extra?: readonly string[], ): (name: string) => boolean { - const baseline = new Set(extra); - for (const entry of view.list()) { - baseline.add(entry.name); - } + let baseline: Set | undefined; let frozen = false; + const snapshot = (): Set => { + if (baseline === undefined) { + baseline = new Set(extra); + for (const entry of view.list()) { + baseline.add(entry.name); + } + } + return baseline; + }; void ready.then( () => { + snapshot(); frozen = true; }, () => { + snapshot(); frozen = true; }, ); return (name) => { - if (baseline.has(name)) return true; + const names = snapshot(); + if (names.has(name)) return true; if (frozen) return false; if (view.get(name) === undefined) return false; - baseline.add(name); + names.add(name); return true; }; } diff --git a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts index fe035cd57d6..aece7427c52 100644 --- a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts @@ -1207,6 +1207,34 @@ describe('AgentMcpService', () => { ]); }); + it('registers the synthetic authenticate tool for a server that settled needs-auth before attach', () => { + const oauthService = { + beginAuthorization: async () => ({ + authorizationUrl: new URL('https://example.com/authorize'), + complete: async () => {}, + cancel: async () => {}, + }), + } as unknown as McpOAuthService; + const manager = new FakeMcpManager({ oauthService }); + manager.needsAuth(); + + createService(manager); + + const tools = ix.get(IAgentToolRegistryService).list(); + expect(tools).toEqual([ + expect.objectContaining({ + name: 'mcp__needs-auth__authenticate', + source: 'mcp', + }), + ]); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'mcp.server.status', + server: expect.objectContaining({ name: 'needs-auth', status: 'needs-auth' }), + }), + ); + }); + it('keeps tools registered when a connected server fails so later calls can heal', async () => { const manager = new FakeMcpManager(); const client = fakeMcpClient(); 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 e4ac57a53d1..02b3687ecb4 100644 --- a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts @@ -33,7 +33,7 @@ import { IWorkspaceMcpService, type ISessionMcpOverlay } from '#/workspace/works import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; import { stubLog } from '../../_base/log/stubs'; -import { createMemoryMcpOAuthStore, stdioFixture } from '../../mcpCore/stubs'; +import { createMemoryMcpOAuthStore, startInProcessHttpMcpServer, stdioFixture } from '../../mcpCore/stubs'; import { registerAgentIdentityStub } from '../../app/agentIdentity/stubs'; function stdioServer(): McpServerConfig { @@ -216,6 +216,35 @@ describe('WorkspaceMcpService', () => { expect(service.sessionHandle().isBaselineServer('late')).toBe(true); }, 20000); + it('sessionHandle admits servers that finished the initial load before the first baseline read', async () => { + current = { alpha: stdioServer() }; + const service = createService(); + manager = service.connectionManager(); + const handle = service.sessionHandle(); + + await service.ready; + expect(manager.get('alpha')?.status).toBe('connected'); + + expect(handle.isBaselineServer('alpha')).toBe(true); + }, 20000); + + it('sessionHandle admits a needs-auth server that settled before the first baseline read', async () => { + const server = await startInProcessHttpMcpServer({ authToken: 'secret' }); + try { + current = { remote: { transport: 'http', url: server.url } }; + const service = createService(); + manager = service.connectionManager(); + const handle = service.sessionHandle(); + + await service.ready; + expect(manager.get('remote')?.status).toBe('needs-auth'); + + expect(handle.isBaselineServer('remote')).toBe(true); + } finally { + await server.close(); + } + }, 20000); + it('sessionOverlay marks the ephemeral server names as baseline by construction', async () => { current = { base: stdioServer() }; const service = createService();