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;
}
181 changes: 179 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,173 @@ 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',
request: {
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
});
});

it('bridges nested MCP URL elicitation requests and preserves URL metadata', async () => {
const {
session,
codexMessages,
rpcHandlers
} = createSessionStub();

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

const elicitationResult = Promise.resolve(elicitationHandler?.({
threadId: 'thread-anonymous',
turnId: 'turn-2',
serverName: 'github-auth',
request: {
mode: 'url',
message: 'Sign in to continue',
url: 'https://example.com/auth',
elicitationId: 'elicitation-123'
}
}));

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-2',
serverName: 'github-auth',
mode: 'url',
message: 'Sign in to continue',
url: 'https://example.com/auth',
elicitationId: 'elicitation-123'
});

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

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

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

const exitReason = await codexRemoteLauncher(session as never);

expect(exitReason).toBe('exit');
expect(harness.registerRequestCalls).toContain('mcpServer/elicitation/request');
});

it('rejects malformed nested MCP elicitation payloads before bridging to the UI', async () => {
const {
session,
codexMessages
} = createSessionStub();

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

await expect(Promise.resolve(elicitationHandler?.({
threadId: 'thread-anonymous',
turnId: 'turn-3',
serverName: 'broken-server',
request: {
message: 'Missing mode'
}
}))).rejects.toThrow('Invalid MCP elicitation request: missing mode');

expect(codexMessages.find((message: any) => message?.name === 'CodexMcpElicitation')).toBeUndefined();
};

const exitReason = await codexRemoteLauncher(session as never);

expect(exitReason).toBe('exit');
});
});
Loading
Loading