From 1bb7cc51073bbab29009cbf7c118d31616723c36 Mon Sep 17 00:00:00 2001 From: 91ac0m0 Date: Tue, 31 Mar 2026 20:38:54 +0800 Subject: [PATCH 1/7] codex: add app-server handler for MCP elicitation --- cli/src/codex/appServerTypes.ts | 25 +++++ .../utils/appServerPermissionAdapter.test.ts | 104 ++++++++++++++++++ .../codex/utils/appServerPermissionAdapter.ts | 18 ++- 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 cli/src/codex/utils/appServerPermissionAdapter.test.ts diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index fdb7fcf6b..2b51892ff 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -144,3 +144,28 @@ export interface TurnInterruptResponse { ok: boolean; [key: string]: unknown; } + +export interface McpElicitationFormRequest { + mode: 'form'; + message: string; + requestedSchema: Record; +} + +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; +} diff --git a/cli/src/codex/utils/appServerPermissionAdapter.test.ts b/cli/src/codex/utils/appServerPermissionAdapter.test.ts new file mode 100644 index 000000000..e463b70cf --- /dev/null +++ b/cli/src/codex/utils/appServerPermissionAdapter.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { McpServerElicitationRequestParams } from '../appServerTypes'; +import { registerAppServerPermissionHandlers } from './appServerPermissionAdapter'; + +const harness = vi.hoisted(() => ({ + loggerDebug: vi.fn() +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: harness.loggerDebug + } +})); + +function createPermissionHandlerStub() { + return { + handleToolCall: vi.fn(async () => ({ decision: 'approved' })) + }; +} + +function createClientStub() { + const handlers = new Map Promise | unknown>(); + + return { + client: { + registerRequestHandler(method: string, handler: (params: unknown) => Promise | unknown) { + handlers.set(method, handler); + } + }, + handlers + }; +} + +describe('registerAppServerPermissionHandlers', () => { + it('registers the MCP elicitation app-server handler', () => { + const { client, handlers } = createClientStub(); + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: createPermissionHandlerStub() as never + }); + + expect(handlers.has('mcpServer/elicitation/request')).toBe(true); + }); + + it('cancels MCP elicitation requests when no handler is provided', async () => { + const { client, handlers } = createClientStub(); + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: createPermissionHandlerStub() as never + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + expect(handler).toBeTypeOf('function'); + + await expect(handler?.({})).resolves.toEqual({ + action: 'cancel', + content: null + }); + expect(harness.loggerDebug).toHaveBeenCalledWith( + '[CodexAppServer] No MCP elicitation handler registered; cancelling request' + ); + }); + + it('returns the MCP elicitation result shape unchanged', async () => { + const { client, handlers } = createClientStub(); + const request: McpServerElicitationRequestParams = { + threadId: 'thread-1', + turnId: 'turn-1', + serverName: 'demo-server', + request: { + mode: 'form', + message: 'Need input', + requestedSchema: { + type: 'object' + } + } + }; + const onMcpElicitationRequest = vi.fn(async () => ({ + action: 'accept' as const, + content: { + token: 'abc' + } + })); + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: createPermissionHandlerStub() as never, + onMcpElicitationRequest + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + expect(handler).toBeTypeOf('function'); + + await expect(handler?.(request)).resolves.toEqual({ + action: 'accept', + content: { + token: 'abc' + } + }); + expect(onMcpElicitationRequest).toHaveBeenCalledWith(request); + }); +}); diff --git a/cli/src/codex/utils/appServerPermissionAdapter.ts b/cli/src/codex/utils/appServerPermissionAdapter.ts index 73c409293..3920fd143 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { logger } from '@/ui/logger'; +import type { McpServerElicitationRequestParams, McpServerElicitationResponse } from '../appServerTypes'; import type { CodexPermissionHandler } from './permissionHandler'; import type { CodexAppServerClient } from '../codexAppServerClient'; @@ -38,8 +39,11 @@ export function registerAppServerPermissionHandlers(args: { client: CodexAppServerClient; permissionHandler: CodexPermissionHandler; onUserInputRequest?: (request: unknown) => Promise>; + onMcpElicitationRequest?: ( + request: McpServerElicitationRequestParams + ) => Promise; }): void { - const { client, permissionHandler, onUserInputRequest } = args; + const { client, permissionHandler, onUserInputRequest, onMcpElicitationRequest } = args; client.registerRequestHandler('item/commandExecution/requestApproval', async (params) => { const record = asRecord(params) ?? {}; @@ -91,4 +95,16 @@ export function registerAppServerPermissionHandlers(args: { answers }; }); + + client.registerRequestHandler('mcpServer/elicitation/request', async (params) => { + if (!onMcpElicitationRequest) { + logger.debug('[CodexAppServer] No MCP elicitation handler registered; cancelling request'); + return { + action: 'cancel', + content: null + } satisfies McpServerElicitationResponse; + } + + return await onMcpElicitationRequest(params as McpServerElicitationRequestParams); + }); } From 6b684accd2f1be20f8eb3ebf9450e002bb5707f1 Mon Sep 17 00:00:00 2001 From: 91ac0m0 Date: Tue, 31 Mar 2026 20:41:37 +0800 Subject: [PATCH 2/7] codex: bridge MCP elicitation through remote launcher --- cli/src/codex/codexRemoteLauncher.test.ts | 96 ++++++++++++++++++- cli/src/codex/codexRemoteLauncher.ts | 112 +++++++++++++++++++++- 2 files changed, 205 insertions(+), 3 deletions(-) diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 6d1b2c570..28247b092 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -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 Promise | unknown>(), + startTurnHook: null as null | (() => Promise) })); vi.mock('./codexAppServerClient', () => { @@ -23,8 +25,9 @@ vi.mock('./codexAppServerClient', () => { this.notificationHandler = handler; } - registerRequestHandler(method: string): void { + registerRequestHandler(method: string, handler: (params: unknown) => Promise | unknown): void { harness.registerRequestCalls.push(method); + harness.requestHandlers.set(method, handler); } async startThread(): Promise<{ thread: { id: string }; model: string }> { @@ -36,6 +39,9 @@ vi.mock('./codexAppServerClient', () => { } async startTurn(): Promise<{ turn: Record }> { + if (harness.startTurnHook) { + await harness.startTurnHook(); + } const started = { turn: {} }; harness.notifications.push({ method: 'turn/started', params: started }); this.notificationHandler?.('turn/started', started); @@ -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 () => { @@ -198,4 +206,88 @@ 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 | 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 + }); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index be648d65d..1ab1f0f51 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -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'; @@ -24,6 +25,14 @@ import { type HappyServer = Awaited>['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; @@ -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(); constructor(session: CodexSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -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'); @@ -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 { logger.debug('[codex-remote]: Exiting agent via Ctrl-C'); this.exitReason = 'exit'; @@ -229,6 +250,89 @@ 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 request = params.request; + const requestId = request.mode === 'url' ? request.elicitationId : randomUUID(); + + return { + requestId, + threadId: params.threadId, + turnId: params.turnId, + serverName: params.serverName, + mode: request.mode, + message: request.message, + requestedSchema: request.mode === 'form' ? request.requestedSchema : undefined, + url: request.mode === 'url' ? request.url : undefined, + elicitationId: request.mode === 'url' ? request.elicitationId : undefined + }; + }; + + const waitForMcpElicitationResponse = async (requestId: string): Promise => { + return await new Promise((resolve) => { + this.pendingMcpElicitationRequests.set(requestId, { resolve }); + }); + }; + + const handleMcpElicitationResponse = async (response: unknown): Promise => { + 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 => { + 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) => { const msgType = asString(msg.type); if (!msgType) return; @@ -495,7 +599,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { registerAppServerPermissionHandlers({ client: appServerClient, - permissionHandler + permissionHandler, + onMcpElicitationRequest: handleMcpElicitationRequest }); appServerClient.setNotificationHandler((method, params) => { @@ -513,6 +618,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { onAbort: () => this.handleAbort(), onSwitch: () => this.handleSwitchRequest() }); + session.client.rpcHandlerManager.registerHandler( + 'mcp-elicitation-response', + handleMcpElicitationResponse + ); function logActiveHandles(tag: string) { if (!process.env.DEBUG) return; @@ -725,6 +834,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } this.permissionHandler?.reset(); + this.cancelPendingMcpElicitationRequests(); this.reasoningProcessor?.abort(); this.diffProcessor?.reset(); this.permissionHandler = null; From 0fe8747a09c03f0ef64f11869c1a3b3050872e5f Mon Sep 17 00:00:00 2001 From: 91ac0m0 Date: Tue, 31 Mar 2026 20:46:46 +0800 Subject: [PATCH 3/7] mcp: add elicitation protocol and web UI --- hub/src/sync/rpcGateway.ts | 14 ++ hub/src/sync/syncEngine.ts | 10 + hub/src/web/routes/mcpElicitation.ts | 39 ++++ hub/src/web/server.ts | 2 + shared/src/schemas.ts | 11 ++ shared/src/types.ts | 2 + web/src/api/client.ts | 15 ++ web/src/chat/normalizeAgent.ts | 2 +- .../ToolCard/CodexMcpElicitationFooter.tsx | 181 ++++++++++++++++++ web/src/components/ToolCard/ToolCard.tsx | 16 +- .../ToolCard/codexMcpElicitation.ts | 101 ++++++++++ web/src/components/ToolCard/knownTools.tsx | 16 ++ .../views/CodexMcpElicitationView.tsx | 62 ++++++ web/src/components/ToolCard/views/_all.tsx | 7 +- web/src/types/api.ts | 2 + 15 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 hub/src/web/routes/mcpElicitation.ts create mode 100644 web/src/components/ToolCard/CodexMcpElicitationFooter.tsx create mode 100644 web/src/components/ToolCard/codexMcpElicitation.ts create mode 100644 web/src/components/ToolCard/views/CodexMcpElicitationView.tsx diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d59ff3b6d..1ae84222a 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,4 +1,5 @@ import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { McpElicitationAction } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -81,6 +82,19 @@ export class RpcGateway { }) } + async respondToMcpElicitation( + sessionId: string, + requestId: string, + action: McpElicitationAction, + content?: unknown | null + ): Promise { + await this.sessionRpc(sessionId, 'mcp-elicitation-response', { + id: requestId, + action, + content: content ?? null + }) + } + async abortSession(sessionId: string): Promise { await this.sessionRpc(sessionId, 'abort', { reason: 'User aborted via Telegram Bot' }) } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..35943005f 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,6 +8,7 @@ */ import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { McpElicitationAction } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -265,6 +266,15 @@ export class SyncEngine { await this.rpcGateway.denyPermission(sessionId, requestId, decision) } + async respondToMcpElicitation( + sessionId: string, + requestId: string, + action: McpElicitationAction, + content?: unknown | null + ): Promise { + await this.rpcGateway.respondToMcpElicitation(sessionId, requestId, action, content) + } + async abortSession(sessionId: string): Promise { await this.rpcGateway.abortSession(sessionId) } diff --git a/hub/src/web/routes/mcpElicitation.ts b/hub/src/web/routes/mcpElicitation.ts new file mode 100644 index 000000000..14a5ed29f --- /dev/null +++ b/hub/src/web/routes/mcpElicitation.ts @@ -0,0 +1,39 @@ +import { McpElicitationResponseSchema } from '@hapi/protocol/schemas' +import { Hono } from 'hono' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSessionFromParam, requireSyncEngine } from './guards' + +export function createMcpElicitationRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.post('/sessions/:id/mcp-elicitation/:requestId/respond', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const requestId = c.req.param('requestId') + const json = await c.req.json().catch(() => null) + const parsed = McpElicitationResponseSchema.omit({ id: true }).safeParse(json ?? {}) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + await engine.respondToMcpElicitation( + sessionResult.sessionId, + requestId, + parsed.data.action, + parsed.data.content ?? null + ) + + return c.json({ ok: true }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 08800fc72..a12254e0d 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -14,6 +14,7 @@ import { createEventsRoutes } from './routes/events' import { createSessionsRoutes } from './routes/sessions' import { createMessagesRoutes } from './routes/messages' import { createPermissionsRoutes } from './routes/permissions' +import { createMcpElicitationRoutes } from './routes/mcpElicitation' import { createMachinesRoutes } from './routes/machines' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' @@ -93,6 +94,7 @@ function createWebApp(options: { app.route('/api', createSessionsRoutes(options.getSyncEngine)) app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) + app.route('/api', createMcpElicitationRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createGitRoutes(options.getSyncEngine)) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 52ec83737..8be5c915d 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -87,6 +87,17 @@ export const AgentStateSchema = z.object({ export type AgentState = z.infer +export const McpElicitationActionSchema = z.enum(['accept', 'decline', 'cancel']) +export type McpElicitationAction = z.infer + +export const McpElicitationResponseSchema = z.object({ + id: z.string(), + action: McpElicitationActionSchema, + content: z.unknown().nullish() +}) + +export type McpElicitationResponse = z.infer + export const TodoItemSchema = z.object({ content: z.string(), status: z.enum(['pending', 'in_progress', 'completed']), diff --git a/shared/src/types.ts b/shared/src/types.ts index 37333a60e..c34a912d8 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -5,6 +5,8 @@ export type { AttachmentMetadata, DecryptedMessage, Metadata, + McpElicitationAction, + McpElicitationResponse, Session, SyncEvent, TeamMember, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 163eb206d..ecb582dfd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -9,6 +9,7 @@ import type { GitCommandResponse, MachinePathsExistsResponse, MachinesResponse, + McpElicitationAction, MessagesResponse, PermissionMode, PushSubscriptionPayload, @@ -366,6 +367,20 @@ export class ApiClient { }) } + async respondToMcpElicitation( + sessionId: string, + requestId: string, + payload: { + action: McpElicitationAction + content?: unknown | null + } + ): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/mcp-elicitation/${encodeURIComponent(requestId)}/respond`, { + method: 'POST', + body: JSON.stringify(payload) + }) + } + async getMachines(): Promise { return await this.request('/api/machines') } diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 18886e989..28cd5c663 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -372,7 +372,7 @@ export function normalizeAgentRecord( type: 'tool-result', tool_use_id: data.callId, content: data.output, - is_error: false, + is_error: Boolean(data.is_error), uuid, parentUUID: null }], diff --git a/web/src/components/ToolCard/CodexMcpElicitationFooter.tsx b/web/src/components/ToolCard/CodexMcpElicitationFooter.tsx new file mode 100644 index 000000000..58534da65 --- /dev/null +++ b/web/src/components/ToolCard/CodexMcpElicitationFooter.tsx @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ApiClient } from '@/api/client' +import type { ChatToolCall } from '@/chat/types' +import { CodeBlock } from '@/components/CodeBlock' +import { Spinner } from '@/components/Spinner' +import { + isCodexMcpElicitationToolName, + parseCodexMcpElicitationInput +} from '@/components/ToolCard/codexMcpElicitation' +import { usePlatform } from '@/hooks/usePlatform' + +function ActionButton(props: { + label: string + tone: 'allow' | 'deny' | 'neutral' + loading?: boolean + disabled: boolean + onClick: () => void +}) { + const base = 'flex w-full items-center justify-between rounded-md px-2 py-2 text-sm text-left transition-colors disabled:pointer-events-none disabled:opacity-50 hover:bg-[var(--app-subtle-bg)]' + const tone = props.tone === 'allow' + ? 'text-emerald-600' + : props.tone === 'deny' + ? 'text-red-600' + : 'text-[var(--app-link)]' + + return ( + + ) +} + +export function CodexMcpElicitationFooter(props: { + api: ApiClient + sessionId: string + tool: ChatToolCall + disabled: boolean + onDone: () => void +}) { + const { haptic } = usePlatform() + const parsed = useMemo(() => parseCodexMcpElicitationInput(props.tool.input), [props.tool.input]) + const [jsonContent, setJsonContent] = useState('{}') + const [loading, setLoading] = useState<'accept' | 'decline' | 'cancel' | null>(null) + const [error, setError] = useState(null) + + useEffect(() => { + setLoading(null) + setError(null) + setJsonContent('{}') + }, [props.tool.id]) + + if (!isCodexMcpElicitationToolName(props.tool.name)) return null + if (!parsed) return null + if (props.tool.state !== 'running' && props.tool.state !== 'pending') return null + + const run = async (action: () => Promise) => { + if (props.disabled) return + setError(null) + try { + await action() + haptic.notification('success') + props.onDone() + } catch (e) { + haptic.notification('error') + setError(e instanceof Error ? e.message : 'Request failed') + } + } + + const submitAccept = async () => { + if (loading) return + setLoading('accept') + + let content: unknown | null = null + if (parsed.mode === 'form') { + try { + content = JSON.parse(jsonContent) + } catch { + setLoading(null) + setError('Form content must be valid JSON') + return + } + } + + await run(() => props.api.respondToMcpElicitation(props.sessionId, parsed.requestId, { + action: 'accept', + content + })) + setLoading(null) + } + + const submitSimple = async (action: 'decline' | 'cancel') => { + if (loading) return + setLoading(action) + await run(() => props.api.respondToMcpElicitation(props.sessionId, parsed.requestId, { + action, + content: null + })) + setLoading(null) + } + + return ( +
+
+ MCP elicitation request from {parsed.serverName} +
+ + {parsed.message ? ( +
+ {parsed.message} +
+ ) : null} + + {parsed.mode === 'form' ? ( +
+
Schema
+ +
Content JSON
+