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/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 6d1b2c570..4e9433c4f 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,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 | 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 + }); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index be648d65d..d2b0594c0 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,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) ?? {}; + 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 => { + 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 +617,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { registerAppServerPermissionHandlers({ client: appServerClient, - permissionHandler + permissionHandler, + onMcpElicitationRequest: handleMcpElicitationRequest }); appServerClient.setNotificationHandler((method, params) => { @@ -513,6 +636,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 +852,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } this.permissionHandler?.reset(); + this.cancelPendingMcpElicitationRequests(); this.reasoningProcessor?.abort(); this.diffProcessor?.reset(); this.permissionHandler = 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); + }); } 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..f3a1b36c0 --- /dev/null +++ b/web/src/components/ToolCard/CodexMcpElicitationFooter.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ApiClient } from '@/api/client' +import type { ChatToolCall } from '@/chat/types' +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 [loading, setLoading] = useState<'accept' | 'decline' | null>(null) + const [error, setError] = useState(null) + + useEffect(() => { + setLoading(null) + setError(null) + }, [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') { + content = {} + } + + await run(() => props.api.respondToMcpElicitation(props.sessionId, parsed.requestId, { + action: 'accept', + content + })) + setLoading(null) + } + + const submitDecline = async () => { + if (loading) return + setLoading('decline') + await run(() => props.api.respondToMcpElicitation(props.sessionId, parsed.requestId, { + action: 'decline', + content: null + })) + setLoading(null) + } + + return ( +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+
+ ) +} diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index b8705c047..dddd7971b 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { PermissionFooter } from '@/components/ToolCard/PermissionFooter' import { AskUserQuestionFooter } from '@/components/ToolCard/AskUserQuestionFooter' import { RequestUserInputFooter } from '@/components/ToolCard/RequestUserInputFooter' +import { CodexMcpElicitationFooter } from '@/components/ToolCard/CodexMcpElicitationFooter' import { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion' +import { isCodexMcpElicitationToolName } from '@/components/ToolCard/codexMcpElicitation' import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInput' import { getToolPresentation } from '@/components/ToolCard/knownTools' import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all' @@ -313,12 +315,16 @@ function ToolCardInner(props: ToolCardProps) { const permission = props.block.tool.permission const isAskUserQuestion = isAskUserQuestionToolName(toolName) const isRequestUserInput = isRequestUserInputToolName(toolName) + const isCodexMcpElicitation = isCodexMcpElicitationToolName(toolName) const isQuestionTool = isAskUserQuestion || isRequestUserInput + const showsMcpElicitationFooter = isCodexMcpElicitation && ( + props.block.tool.state === 'pending' || props.block.tool.state === 'running' + ) const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' || ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason)) )) - const hasBody = showInline || taskSummary !== null || showsPermissionFooter + const hasBody = showInline || taskSummary !== null || showsPermissionFooter || showsMcpElicitationFooter const stateColor = statusColorClass(props.block.tool.state) const { suppressFocusRing, onTriggerPointerDown, onTriggerKeyDown, onTriggerBlur } = usePointerFocusRing() @@ -448,6 +454,14 @@ function ToolCardInner(props: ToolCardProps) { disabled={props.disabled} onDone={props.onDone} /> + ) : isCodexMcpElicitation ? ( + ) : ( + meta?: { + toolTitle?: string + toolDescription?: string + } + url?: undefined + elicitationId?: undefined + } + | { + requestId: string + threadId: string + turnId: string | null + serverName: string + mode: 'url' + message: string + url: string + meta?: { + toolTitle?: string + toolDescription?: string + } + elicitationId?: string + requestedSchema?: undefined + } + +export type CodexMcpElicitationResult = { + action: 'accept' | 'decline' | 'cancel' + content: unknown | null +} + +export function isCodexMcpElicitationToolName(toolName: string): boolean { + return toolName === 'CodexMcpElicitation' +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +export function parseCodexMcpElicitationInput(input: unknown): CodexMcpElicitationInput | null { + if (!isObject(input)) return null + + const requestId = asString(input.requestId) + const threadId = asString(input.threadId) + const serverName = asString(input.serverName) + const mode = input.mode + const message = asString(input.message) ?? '' + const turnId = typeof input.turnId === 'string' ? input.turnId : null + const meta = isObject(input._meta) + ? { + toolTitle: asString(input._meta.tool_title) ?? undefined, + toolDescription: asString(input._meta.tool_description) ?? undefined + } + : undefined + + if (!requestId || !threadId || !serverName) return null + + if (mode === 'form' && isObject(input.requestedSchema)) { + return { + requestId, + threadId, + turnId, + serverName, + mode, + message, + requestedSchema: input.requestedSchema as Record, + meta + } + } + + if (mode === 'url') { + const url = asString(input.url) + if (!url) return null + return { + requestId, + threadId, + turnId, + serverName, + mode, + message, + url, + meta, + elicitationId: asString(input.elicitationId) ?? undefined + } + } + + return null +} + +export function parseCodexMcpElicitationResult(result: unknown): CodexMcpElicitationResult | null { + if (typeof result === 'string') { + try { + return parseCodexMcpElicitationResult(JSON.parse(result)) + } catch { + return null + } + } + + if (!isObject(result)) return null + const action = result.action + if (action !== 'accept' && action !== 'decline' && action !== 'cancel') { + return null + } + + return { + action, + content: result.content ?? null + } +} diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 7289ec189..ae54ee8f2 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -6,6 +6,7 @@ import type { ChecklistItem } from '@/components/ToolCard/checklist' import { extractTodoChecklist, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist' import { basename, resolveDisplayPath } from '@/utils/path' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' +import { parseCodexMcpElicitationInput } from '@/components/ToolCard/codexMcpElicitation' const DEFAULT_ICON_CLASS = 'h-3.5 w-3.5' // Tool presentation registry for `hapi/web` (aligned with `hapi-app`). @@ -170,6 +171,25 @@ export const knownTools: Record getInputStringAny(opts.input, ['message', 'command']) ?? null, minimal: true }, + CodexMcpElicitation: { + icon: () => , + title: (opts) => { + const parsed = parseCodexMcpElicitationInput(opts.input) + if (!parsed) return 'MCP elicitation' + const toolTitle = parsed.meta?.toolTitle + if (toolTitle) { + return parsed.mode === 'url' ? `Approve Sign-in: ${toolTitle}` : `Approve Tool: ${toolTitle}` + } + return parsed.mode === 'url' + ? `Approve Sign-in: ${parsed.serverName}` + : `Approve Tool: ${parsed.serverName}` + }, + subtitle: (opts) => { + const parsed = parseCodexMcpElicitationInput(opts.input) + return parsed?.meta?.toolDescription ?? null + }, + minimal: true + }, shell_command: { icon: () => , title: (opts) => opts.description ?? 'Terminal', diff --git a/web/src/components/ToolCard/views/CodexMcpElicitationView.tsx b/web/src/components/ToolCard/views/CodexMcpElicitationView.tsx new file mode 100644 index 000000000..04aadf3a6 --- /dev/null +++ b/web/src/components/ToolCard/views/CodexMcpElicitationView.tsx @@ -0,0 +1,62 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { + parseCodexMcpElicitationInput, + parseCodexMcpElicitationResult +} from '@/components/ToolCard/codexMcpElicitation' +import { CodeBlock } from '@/components/CodeBlock' + +export function CodexMcpElicitationView(props: ToolViewProps) { + const input = parseCodexMcpElicitationInput(props.block.tool.input) + const result = parseCodexMcpElicitationResult(props.block.tool.result) + + if (!input) { + return null + } + + return ( +
+
+
Server
+
{input.serverName}
+ {input.message ? ( + <> +
Message
+
{input.message}
+ + ) : null} + {input.mode === 'form' ? ( + <> +
Requested schema
+
+ +
+ + ) : ( + <> +
URL
+ + {input.url} + + + )} +
+ + {result ? ( +
+
Response
+
{result.action}
+ {result.content !== null ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx index e1b9c7c88..61ddab9df 100644 --- a/web/src/components/ToolCard/views/_all.tsx +++ b/web/src/components/ToolCard/views/_all.tsx @@ -5,6 +5,7 @@ import { CodexDiffCompactView, CodexDiffFullView } from '@/components/ToolCard/v import { CodexPatchView } from '@/components/ToolCard/views/CodexPatchView' import { EditView } from '@/components/ToolCard/views/EditView' import { AskUserQuestionView } from '@/components/ToolCard/views/AskUserQuestionView' +import { CodexMcpElicitationView } from '@/components/ToolCard/views/CodexMcpElicitationView' import { RequestUserInputView } from '@/components/ToolCard/views/RequestUserInputView' import { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView' import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/MultiEditView' @@ -30,7 +31,8 @@ export const toolViewRegistry: Record = { ExitPlanMode: ExitPlanModeView, ask_user_question: AskUserQuestionView, exit_plan_mode: ExitPlanModeView, - request_user_input: RequestUserInputView + request_user_input: RequestUserInputView, + CodexMcpElicitation: CodexMcpElicitationView } export const toolFullViewRegistry: Record = { @@ -43,7 +45,8 @@ export const toolFullViewRegistry: Record = { ExitPlanMode: ExitPlanModeView, ask_user_question: AskUserQuestionView, exit_plan_mode: ExitPlanModeView, - request_user_input: RequestUserInputView + request_user_input: RequestUserInputView, + CodexMcpElicitation: CodexMcpElicitationView } export function getToolViewComponent(toolName: string): ToolViewComponent | null { diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0a2b01b14..29bd49bd4 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -10,6 +10,8 @@ export type { AgentState, AttachmentMetadata, CodexCollaborationMode, + McpElicitationAction, + McpElicitationResponse, PermissionMode, Session, SessionSummary,