Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import type {
AccountUpdatedNotification,
GetAccountResponse,
ListMcpServerStatusResponse,
McpServerOauthLoginCompletedNotification,
McpServerOauthLoginParams,
McpServerOauthLoginResponse,
Model,
ReviewTarget,
SkillsListParams,
Expand Down Expand Up @@ -1075,6 +1078,19 @@ export class CodexAcpClient {
return this.codexClient.listMcpServerStatus({});
}

async mcpServerOauthLogin(
params: McpServerOauthLoginParams,
): Promise<McpServerOauthLoginResponse> {
return await this.codexClient.mcpServerOauthLogin(params);
}

async awaitMcpServerOauthLoginCompleted(
name: string,
threadId: string,
): Promise<McpServerOauthLoginCompletedNotification> {
return await this.codexClient.awaitMcpServerOauthLoginCompleted(name, threadId);
}

async listSessions(request: acp.ListSessionsRequest): Promise<acp.ListSessionsResponse> {
const sourceKinds: ThreadSourceKind[] = [
"cli",
Expand Down
56 changes: 55 additions & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2182,14 +2182,68 @@ 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,
});
}
}

private async authenticateMcpServer(sessionId: string, serverName: string): Promise<boolean> {
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<void>((resolve) => {
Expand Down
31 changes: 31 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import type {
LogoutAccountResponse,
McpServerElicitationRequestParams,
McpServerElicitationRequestResponse,
McpServerOauthLoginParams,
McpServerOauthLoginResponse,
McpServerOauthLoginCompletedNotification,
McpServerStartupFailureReason,
McpServerStartupState,
McpServerStatusUpdatedNotification,
ModelListParams,
Expand Down Expand Up @@ -90,6 +94,7 @@ export interface ElicitationHandler {
export type McpStartupFailure = {
server: string;
error: string;
failureReason?: McpServerStartupFailureReason;
};

export type McpStartupResult = {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -584,6 +590,29 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "mcpServerStatus/list", params });
}

async mcpServerOauthLogin(params: McpServerOauthLoginParams): Promise<McpServerOauthLoginResponse> {
return await this.sendRequest({ method: "mcpServer/oauth/login", params });
}

async awaitMcpServerOauthLoginCompleted(
name: string,
threadId: string,
): Promise<McpServerOauthLoginCompletedNotification> {
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<LoginAccountResponse> {
return await this.sendRequest({ method: "account/login/start", params: params });
}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -1001,6 +1031,7 @@ export interface ExperimentalThreadSettingsUpdateParams {
type McpServerStartupSnapshot = {
status: McpServerStartupState;
error: string | null;
failureReason: McpServerStartupFailureReason | null;
version: number;
};

Expand Down
36 changes: 36 additions & 0 deletions src/__tests__/CodexACPAgent/elicitation-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand Down