Skip to content
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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MINOR] The web side now reads MCP tool titles/descriptions from input._meta, but this bridge never forwards _meta into the tool-call payload. That makes the new presentation logic in web/src/components/ToolCard/codexMcpElicitation.ts:374 and web/src/components/ToolCard/knownTools.tsx:179 unreachable, so every elicitation still renders with the raw serverName fallback.

Suggested fix:

const meta = asRecord(requestRecord._meta)

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,
    _meta: meta ?? undefined
}

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