Skip to content
Open
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
25 changes: 25 additions & 0 deletions cli/src/codex/appServerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,28 @@ export interface TurnInterruptResponse {
ok: boolean;
[key: string]: unknown;
}

export interface McpElicitationFormRequest {
mode: 'form';
message: string;
requestedSchema: Record<string, unknown>;
}

export interface McpElicitationUrlRequest {
mode: 'url';
message: string;
url: string;
elicitationId: string;
}

export interface McpServerElicitationRequestParams {
threadId: string;
turnId: string | null;
serverName: string;
request: McpElicitationFormRequest | McpElicitationUrlRequest;
}

export interface McpServerElicitationResponse {
action: 'accept' | 'decline' | 'cancel';
content: unknown | null;
}
94 changes: 92 additions & 2 deletions cli/src/codex/codexRemoteLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import type { EnhancedMode } from './loop';
const harness = vi.hoisted(() => ({
notifications: [] as Array<{ method: string; params: unknown }>,
registerRequestCalls: [] as string[],
initializeCalls: [] as unknown[]
initializeCalls: [] as unknown[],
requestHandlers: new Map<string, (params: unknown) => Promise<unknown> | unknown>(),
startTurnHook: null as null | (() => Promise<void>)
}));

vi.mock('./codexAppServerClient', () => {
Expand All @@ -23,8 +25,9 @@ vi.mock('./codexAppServerClient', () => {
this.notificationHandler = handler;
}

registerRequestHandler(method: string): void {
registerRequestHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void {
harness.registerRequestCalls.push(method);
harness.requestHandlers.set(method, handler);
}

async startThread(): Promise<{ thread: { id: string }; model: string }> {
Expand All @@ -36,6 +39,9 @@ vi.mock('./codexAppServerClient', () => {
}

async startTurn(): Promise<{ turn: Record<string, never> }> {
if (harness.startTurnHook) {
await harness.startTurnHook();
}
const started = { turn: {} };
harness.notifications.push({ method: 'turn/started', params: started });
this.notificationHandler?.('turn/started', started);
Expand Down Expand Up @@ -168,6 +174,8 @@ describe('codexRemoteLauncher', () => {
harness.notifications = [];
harness.registerRequestCalls = [];
harness.initializeCalls = [];
harness.requestHandlers.clear();
harness.startTurnHook = null;
});

it('finishes a turn and emits ready when task lifecycle events omit turn_id', async () => {
Expand Down Expand Up @@ -198,4 +206,86 @@ describe('codexRemoteLauncher', () => {
expect(thinkingChanges).toContain(true);
expect(session.thinking).toBe(false);
});

it('bridges MCP elicitation requests through the remote launcher RPC channel', async () => {
const {
session,
codexMessages,
rpcHandlers
} = createSessionStub();
let elicitationResult: Promise<unknown> | null = null;

harness.startTurnHook = async () => {
const elicitationHandler = harness.requestHandlers.get('mcpServer/elicitation/request');
expect(elicitationHandler).toBeTypeOf('function');

elicitationResult = Promise.resolve(elicitationHandler?.({
threadId: 'thread-anonymous',
turnId: 'turn-1',
serverName: 'demo-server',
mode: 'form',
message: 'Need MCP input',
requestedSchema: {
type: 'object',
properties: {
token: { type: 'string' }
}
}
}));

await Promise.resolve();

const requestMessage = codexMessages.find((message: any) => message?.name === 'CodexMcpElicitation') as any;
expect(requestMessage).toBeTruthy();
expect(requestMessage.input).toMatchObject({
threadId: 'thread-anonymous',
turnId: 'turn-1',
serverName: 'demo-server',
mode: 'form',
message: 'Need MCP input'
});

const rpcHandler = rpcHandlers.get('mcp-elicitation-response');
expect(rpcHandler).toBeTypeOf('function');

await rpcHandler?.({
id: requestMessage.callId,
action: 'accept',
content: {
token: 'abc'
}
});

await expect(elicitationResult).resolves.toEqual({
action: 'accept',
content: {
token: 'abc'
}
});
};

const exitReason = await codexRemoteLauncher(session as never);

expect(exitReason).toBe('exit');
expect(rpcHandlers.has('mcp-elicitation-response')).toBe(true);
expect(harness.registerRequestCalls).toContain('mcpServer/elicitation/request');

const requestMessage = codexMessages.find((message: any) => message?.name === 'CodexMcpElicitation') as any;
const resultMessage = codexMessages.find((message: any) => (
message?.type === 'tool-call-result' && message?.callId === requestMessage?.callId
)) as any;

expect(requestMessage).toBeTruthy();
expect(resultMessage).toMatchObject({
type: 'tool-call-result',
callId: requestMessage.callId,
output: {
action: 'accept',
content: {
token: 'abc'
}
},
is_error: false
});
});
});
130 changes: 129 additions & 1 deletion cli/src/codex/codexRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge';
import { emitReadyIfIdle } from './utils/emitReadyIfIdle';
import type { CodexSession } from './session';
import type { EnhancedMode } from './loop';
import type { McpServerElicitationRequestParams, McpServerElicitationResponse } from './appServerTypes';
import { hasCodexCliOverrides } from './utils/codexCliOverrides';
import { AppServerEventConverter } from './utils/appServerEventConverter';
import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter';
Expand All @@ -24,6 +25,14 @@ import {

type HappyServer = Awaited<ReturnType<typeof buildHapiMcpBridge>>['server'];
type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string };
type McpElicitationRpcResponse = {
id: string;
action: 'accept' | 'decline' | 'cancel';
content?: unknown | null;
};
type PendingMcpElicitationRequest = {
resolve: (response: McpServerElicitationResponse) => void;
};

class CodexRemoteLauncher extends RemoteLauncherBase {
private readonly session: CodexSession;
Expand All @@ -35,6 +44,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
private abortController: AbortController = new AbortController();
private currentThreadId: string | null = null;
private currentTurnId: string | null = null;
private readonly pendingMcpElicitationRequests = new Map<string, PendingMcpElicitationRequest>();

constructor(session: CodexSession) {
super(process.env.DEBUG ? session.logPath : undefined);
Expand Down Expand Up @@ -64,6 +74,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
this.abortController.abort();
this.session.queue.reset();
this.permissionHandler?.reset();
this.cancelPendingMcpElicitationRequests();
this.reasoningProcessor?.abort();
this.diffProcessor?.reset();
logger.debug('[Codex] Abort completed - session remains active');
Expand All @@ -74,6 +85,16 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
}
}

private cancelPendingMcpElicitationRequests(): void {
for (const [requestId, pending] of this.pendingMcpElicitationRequests.entries()) {
pending.resolve({
action: 'cancel',
content: null
});
this.pendingMcpElicitationRequests.delete(requestId);
}
}

private async handleExitFromUi(): Promise<void> {
logger.debug('[codex-remote]: Exiting agent via Ctrl-C');
this.exitReason = 'exit';
Expand Down Expand Up @@ -229,6 +250,107 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
let turnInFlight = false;
let allowAnonymousTerminalEvent = false;

const toMcpElicitationResponse = (response: McpElicitationRpcResponse): McpServerElicitationResponse => ({
action: response.action,
content: response.action === 'accept' ? response.content ?? null : null
});

const parseMcpElicitationRequest = (params: McpServerElicitationRequestParams) => {
const paramsRecord = asRecord(params) ?? {};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] McpServerElicitationRequestParams puts the actual elicitation payload under params.request, but this parser reads mode/message/requestedSchema/url from the top level. With the typed app-server shape, mode stays null here and the request is rejected before it ever reaches the hub/web bridge.

Suggested fix:

const requestRecord = asRecord(params.request) ?? {}
const mode = asString(requestRecord.mode)
const message = asString(requestRecord.message) ?? ''
const requestedSchema = asRecord(requestRecord.requestedSchema)
const url = asString(requestRecord.url)
const elicitationId = asString(requestRecord.elicitationId)

const mode = asString(paramsRecord.mode);
const message = asString(paramsRecord.message) ?? '';
const requestedSchema = asRecord(paramsRecord.requestedSchema);
const url = asString(paramsRecord.url);
const elicitationId = asString(paramsRecord.elicitationId);

if (mode !== 'form' && mode !== 'url') {
throw new Error('Invalid MCP elicitation request: missing mode');
}

if (mode === 'form' && !requestedSchema) {
throw new Error('Invalid MCP elicitation form request: missing requestedSchema');
}

if (mode === 'url' && !url) {
throw new Error('Invalid MCP elicitation URL request: missing url');
}

const requestId = mode === 'url' ? (elicitationId ?? randomUUID()) : randomUUID();

return {
requestId,
threadId: params.threadId,
turnId: params.turnId,
serverName: params.serverName,
mode,
message,
requestedSchema: mode === 'form' ? requestedSchema : undefined,
url: mode === 'url' ? url : undefined,
elicitationId: mode === 'url' ? elicitationId : undefined
};
};

const waitForMcpElicitationResponse = async (requestId: string): Promise<McpServerElicitationResponse> => {
return await new Promise<McpServerElicitationResponse>((resolve) => {
this.pendingMcpElicitationRequests.set(requestId, { resolve });
});
};

const handleMcpElicitationResponse = async (response: unknown): Promise<void> => {
const record = asRecord(response) ?? {};
const requestId = asString(record.id);
const action = asString(record.action);
if (!requestId || (action !== 'accept' && action !== 'decline' && action !== 'cancel')) {
logger.debug('[Codex] Ignoring invalid MCP elicitation response payload', response);
return;
}

const pending = this.pendingMcpElicitationRequests.get(requestId);
if (!pending) {
logger.debug(`[Codex] No pending MCP elicitation request for id ${requestId}`);
return;
}

this.pendingMcpElicitationRequests.delete(requestId);
pending.resolve(toMcpElicitationResponse({
id: requestId,
action,
content: record.content
}));
};

const handleMcpElicitationRequest = async (
params: McpServerElicitationRequestParams
): Promise<McpServerElicitationResponse> => {
const request = parseMcpElicitationRequest(params);

logger.debug('[Codex] Bridging MCP elicitation request', {
requestId: request.requestId,
mode: request.mode,
serverName: request.serverName
});

session.sendAgentMessage({
type: 'tool-call',
name: 'CodexMcpElicitation',
callId: request.requestId,
input: request,
id: randomUUID()
});

const result = await waitForMcpElicitationResponse(request.requestId);

session.sendAgentMessage({
type: 'tool-call-result',
callId: request.requestId,
output: result,
is_error: result.action !== 'accept',
id: randomUUID()
});

return result;
};

const handleCodexEvent = (msg: Record<string, unknown>) => {
const msgType = asString(msg.type);
if (!msgType) return;
Expand Down Expand Up @@ -495,7 +617,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase {

registerAppServerPermissionHandlers({
client: appServerClient,
permissionHandler
permissionHandler,
onMcpElicitationRequest: handleMcpElicitationRequest
});

appServerClient.setNotificationHandler((method, params) => {
Expand All @@ -513,6 +636,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
onAbort: () => this.handleAbort(),
onSwitch: () => this.handleSwitchRequest()
});
session.client.rpcHandlerManager.registerHandler<McpElicitationRpcResponse, void>(
'mcp-elicitation-response',
handleMcpElicitationResponse
);

function logActiveHandles(tag: string) {
if (!process.env.DEBUG) return;
Expand Down Expand Up @@ -725,6 +852,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
}

this.permissionHandler?.reset();
this.cancelPendingMcpElicitationRequests();
this.reasoningProcessor?.abort();
this.diffProcessor?.reset();
this.permissionHandler = null;
Expand Down
Loading
Loading