diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 68b74e00..9903cfbf 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -34,6 +34,9 @@ import type { AccountUpdatedNotification, GetAccountResponse, ListMcpServerStatusResponse, + McpServerOauthLoginCompletedNotification, + McpServerOauthLoginParams, + McpServerOauthLoginResponse, Model, ReviewTarget, SkillsListParams, @@ -1075,6 +1078,19 @@ export class CodexAcpClient { return this.codexClient.listMcpServerStatus({}); } + async mcpServerOauthLogin( + params: McpServerOauthLoginParams, + ): Promise { + return await this.codexClient.mcpServerOauthLogin(params); + } + + async awaitMcpServerOauthLoginCompleted( + name: string, + threadId: string, + ): Promise { + return await this.codexClient.awaitMcpServerOauthLoginCompleted(name, threadId); + } + async listSessions(request: acp.ListSessionsRequest): Promise { const sourceKinds: ThreadSourceKind[] = [ "cli", diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 2c7937f3..d2953d7e 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -2182,7 +2182,32 @@ export class CodexAcpServer { } : mcpStartup; - for (const update of CodexEventHandler.createMcpStartupUpdates(filteredStartup)) { + const failuresAfterOauth: typeof filteredStartup.failed = []; + const readyAfterOauth = [...filteredStartup.ready]; + for (const failure of filteredStartup.failed) { + if (failure.failureReason !== "reauthenticationRequired" + || !clientSupportsUrlElicitation(this.clientCapabilities)) { + failuresAfterOauth.push(failure); + continue; + } + try { + const authenticated = await this.authenticateMcpServer(sessionId, failure.server); + if (authenticated) { + readyAfterOauth.push(failure.server); + } else { + failuresAfterOauth.push(failure); + } + } catch (error) { + logger.error(`Failed to authenticate MCP server ${failure.server}`, error); + failuresAfterOauth.push(failure); + } + } + + for (const update of CodexEventHandler.createMcpStartupUpdates({ + ...filteredStartup, + ready: readyAfterOauth, + failed: failuresAfterOauth, + })) { await this.connection.notify(acp.methods.client.session.update, { sessionId, update, @@ -2190,6 +2215,35 @@ export class CodexAcpServer { } } + private async authenticateMcpServer(sessionId: string, serverName: string): Promise { + const elicitationId = `mcp-oauth-${randomUUID()}`; + const completed = this.codexAcpClient.awaitMcpServerOauthLoginCompleted(serverName, sessionId); + const login = await this.codexAcpClient.mcpServerOauthLogin({ + name: serverName, + threadId: sessionId, + }); + const elicitation = Promise.resolve(this.connection.request( + acp.methods.client.elicitation.create, + { + mode: "url", + sessionId, + message: `Authenticate with MCP server ${serverName}`, + url: login.authorizationUrl, + elicitationId, + }, + )); + const first = await Promise.race([ + completed.then(result => ({type: "completed" as const, result})), + elicitation.then(response => ({type: "elicitation" as const, response})), + ]); + if (first.type === "elicitation" && !acp.CreateElicitationResponse.isAccept(first.response)) { + return false; + } + const result = first.type === "completed" ? first.result : await completed; + await this.connection.notify(acp.methods.client.elicitation.complete, {elicitationId}); + return result.success; + } + private trackActivePrompt(sessionId: string): ActivePrompt { let resolveCompletion: () => void = () => {}; const completion = new Promise((resolve) => { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 0f802d68..d05aeefd 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -19,6 +19,10 @@ import type { LogoutAccountResponse, McpServerElicitationRequestParams, McpServerElicitationRequestResponse, + McpServerOauthLoginParams, + McpServerOauthLoginResponse, + McpServerOauthLoginCompletedNotification, + McpServerStartupFailureReason, McpServerStartupState, McpServerStatusUpdatedNotification, ModelListParams, @@ -90,6 +94,7 @@ export interface ElicitationHandler { export type McpStartupFailure = { server: string; error: string; + failureReason?: McpServerStartupFailureReason; }; export type McpStartupResult = { @@ -160,6 +165,7 @@ export class CodexAppServerClient { this.mcpServerStartupStates.set(serverNotification.params.name, { status: serverNotification.params.status, error: serverNotification.params.error, + failureReason: serverNotification.params.failureReason ?? null, version: this.mcpServerStartupVersion, }); this.resolveMcpServerStartupResolvers(); @@ -584,6 +590,29 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "mcpServerStatus/list", params }); } + async mcpServerOauthLogin(params: McpServerOauthLoginParams): Promise { + return await this.sendRequest({ method: "mcpServer/oauth/login", params }); + } + + async awaitMcpServerOauthLoginCompleted( + name: string, + threadId: string, + ): Promise { + return await new Promise((resolve) => { + let disposable: {dispose(): void} | undefined; + disposable = this.connection.onNotification( + "mcpServer/oauthLogin/completed", + (event: McpServerOauthLoginCompletedNotification) => { + if (event.name !== name || event.threadId !== threadId) { + return; + } + disposable?.dispose(); + resolve(event); + }, + ); + }); + } + async accountLogin(params: LoginAccountParams): Promise { return await this.sendRequest({ method: "account/login/start", params: params }); } @@ -942,6 +971,7 @@ export class CodexAppServerClient { failed.push({ server: serverName, error: state.error ?? "unknown MCP startup error", + ...(state.failureReason === null ? {} : {failureReason: state.failureReason}), }); break; case "cancelled": @@ -1001,6 +1031,7 @@ export interface ExperimentalThreadSettingsUpdateParams { type McpServerStartupSnapshot = { status: McpServerStartupState; error: string | null; + failureReason: McpServerStartupFailureReason | null; version: number; }; diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index c3d44964..7a9521db 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -702,6 +702,42 @@ describe('Elicitation Events', () => { }); describe('URL mode elicitation', () => { + it('maps MCP OAuth login to ACP URL elicitation and completes it', async () => { + const agent = fixture.getCodexAcpAgent(); + const codexClient = fixture.getCodexAcpClient(); + await agent.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { elicitation: { url: {} } }, + }); + fixture.setElicitationResponse({action: 'accept'}); + const oauthLogin = vi.spyOn(codexClient, 'mcpServerOauthLogin').mockResolvedValue({ + authorizationUrl: 'https://example.com/oauth/authorize', + }); + vi.spyOn(codexClient, 'awaitMcpServerOauthLoginCompleted').mockResolvedValue({ + name: 'linear', + threadId: sessionId, + success: true, + }); + + await expect((agent as any).authenticateMcpServer(sessionId, 'linear')).resolves.toBe(true); + + expect(oauthLogin).toHaveBeenCalledWith({name: 'linear', threadId: sessionId}); + const events = fixture.getAcpConnectionEvents([]); + expect(events[0]).toMatchObject({ + method: 'createElicitation', + args: [{ + mode: 'url', + sessionId, + message: 'Authenticate with MCP server linear', + url: 'https://example.com/oauth/authorize', + }], + }); + expect(events[1]).toMatchObject({ + method: 'completeElicitation', + args: [{elicitationId: expect.stringMatching(/^mcp-oauth-/)}], + }); + }); + it('should use ACP URL elicitation when the client supports it', async () => { const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ elicitation: { url: {} },