diff --git a/cli/src/claude/utils/sessionScanner.test.ts b/cli/src/claude/utils/sessionScanner.test.ts index 709b2152b..2150bc9b7 100644 --- a/cli/src/claude/utils/sessionScanner.test.ts +++ b/cli/src/claude/utils/sessionScanner.test.ts @@ -3,21 +3,26 @@ import { createSessionScanner } from './sessionScanner' import { RawJSONLines } from '../types' import { mkdir, writeFile, appendFile, rm, readFile } from 'node:fs/promises' import { join } from 'node:path' -import { tmpdir, homedir } from 'node:os' +import { tmpdir } from 'node:os' import { existsSync } from 'node:fs' describe('sessionScanner', () => { let testDir: string let projectDir: string + let claudeConfigDir: string let collectedMessages: RawJSONLines[] let scanner: Awaited> | null = null + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR beforeEach(async () => { testDir = join(tmpdir(), `scanner-test-${Date.now()}`) await mkdir(testDir, { recursive: true }) + + claudeConfigDir = join(testDir, '.claude') + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir const projectName = testDir.replace(/\//g, '-') - projectDir = join(homedir(), '.claude', 'projects', projectName) + projectDir = join(claudeConfigDir, 'projects', projectName) await mkdir(projectDir, { recursive: true }) collectedMessages = [] @@ -36,6 +41,12 @@ describe('sessionScanner', () => { if (existsSync(projectDir)) { await rm(projectDir, { recursive: true, force: true }) } + + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } }) it('should process initial session and resumed session correctly', async () => { @@ -144,4 +155,4 @@ describe('sessionScanner', () => { expect(content).toContain('readme.md') } }) -}) \ No newline at end of file +}) diff --git a/cli/src/claude/utils/startHookServer.test.ts b/cli/src/claude/utils/startHookServer.test.ts index 2e5b2d57d..ef17a8d14 100644 --- a/cli/src/claude/utils/startHookServer.test.ts +++ b/cli/src/claude/utils/startHookServer.test.ts @@ -1,117 +1,150 @@ import { describe, it, expect } from 'vitest' -import { request } from 'node:http' -import { startHookServer, type SessionHookData } from './startHookServer' - -const sendHookRequest = async (port: number, body: string, token?: string): Promise<{ statusCode?: number; body: string }> => { - return await new Promise((resolve, reject) => { - const headers: Record = { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body) +import { Readable } from 'node:stream' +import { createHookRequestHandler, type SessionHookData } from './startHookServer' + +class MockRequest extends Readable { + headers: Record + method: string + url: string + private sent = false + + constructor(opts: { + body: string + path?: string + token?: string + }) { + super() + this.method = 'POST' + this.url = opts.path ?? '/hook/session-start' + this.headers = { + 'content-type': 'application/json', + 'content-length': `${Buffer.byteLength(opts.body)}` } - if (token) { - headers['x-hapi-hook-token'] = token + if (opts.token) { + this.headers['x-hapi-hook-token'] = opts.token } + this.body = opts.body + } - const req = request({ - host: '127.0.0.1', - port, - path: '/hook/session-start', - method: 'POST', - headers - }, (res) => { - const chunks: Buffer[] = [] - res.on('data', (chunk) => chunks.push(chunk as Buffer)) - res.on('error', reject) - res.on('end', () => { - resolve({ - statusCode: res.statusCode, - body: Buffer.concat(chunks).toString('utf-8') - }) - }) - }) + private readonly body: string - req.on('error', reject) - req.end(body) - }) + _read(): void { + if (this.sent) { + this.push(null) + return + } + this.sent = true + this.push(Buffer.from(this.body, 'utf-8')) + this.push(null) + } +} + +class MockResponse { + statusCode: number | undefined + headersSent = false + writableEnded = false + body = '' + + writeHead(statusCode: number): this { + this.statusCode = statusCode + this.headersSent = true + return this + } + + end(body?: string): this { + if (body) { + this.body += body + } + this.writableEnded = true + return this + } +} + +const sendHookRequest = async ( + handler: ReturnType, + body: string, + token?: string +): Promise<{ statusCode?: number; body: string }> => { + const req = new MockRequest({ body, token }) + const res = new MockResponse() + + await handler(req as never, res as never) + + return { + statusCode: res.statusCode, + body: res.body + } } describe('startHookServer', () => { it('forwards session hook payload to callback', async () => { let received: { sessionId?: string; data?: SessionHookData } = {} - const server = await startHookServer({ + const token = 'test-hook-token' + const handler = createHookRequestHandler({ + token, onSessionHook: (sessionId, data) => { received = { sessionId, data } } }) - try { - const body = JSON.stringify({ session_id: 'session-123', extra: 'ok' }) - const response = await sendHookRequest(server.port, body, server.token) - expect(response.statusCode).toBe(200) - } finally { - server.stop() - } + const body = JSON.stringify({ session_id: 'session-123', extra: 'ok' }) + const response = await sendHookRequest(handler, body, token) + expect(response.statusCode).toBe(200) expect(received.sessionId).toBe('session-123') expect(received.data?.session_id).toBe('session-123') }) it('returns 400 for invalid JSON payloads', async () => { let hookCalled = false - const server = await startHookServer({ + const token = 'test-hook-token' + const handler = createHookRequestHandler({ + token, onSessionHook: () => { hookCalled = true } }) - try { - const response = await sendHookRequest(server.port, '{"session_id":', server.token) - expect(response.statusCode).toBe(400) - expect(response.body).toBe('invalid json') - } finally { - server.stop() - } + const response = await sendHookRequest(handler, '{"session_id":', token) + expect(response.statusCode).toBe(400) + expect(response.body).toBe('invalid json') expect(hookCalled).toBe(false) }) it('returns 422 when session_id is missing', async () => { let hookCalled = false - const server = await startHookServer({ + const token = 'test-hook-token' + const handler = createHookRequestHandler({ + token, onSessionHook: () => { hookCalled = true } }) - try { - const body = JSON.stringify({ extra: 'ok' }) - const response = await sendHookRequest(server.port, body, server.token) - expect(response.statusCode).toBe(422) - expect(response.body).toBe('missing session_id') - } finally { - server.stop() - } + const body = JSON.stringify({ extra: 'ok' }) + const response = await sendHookRequest(handler, body, token) + expect(response.statusCode).toBe(422) + expect(response.body).toBe('missing session_id') expect(hookCalled).toBe(false) }) it('returns 401 when hook token is missing', async () => { let hookCalled = false - const server = await startHookServer({ + const token = 'test-hook-token' + const handler = createHookRequestHandler({ + token, onSessionHook: () => { hookCalled = true } }) - try { - const body = JSON.stringify({ session_id: 'session-123' }) - const response = await sendHookRequest(server.port, body) - expect(response.statusCode).toBe(401) - expect(response.body).toBe('unauthorized') - } finally { - server.stop() - } + const body = JSON.stringify({ session_id: 'session-123' }) + const response = await sendHookRequest(handler, body) + expect(response.statusCode).toBe(401) + expect(response.body).toBe('unauthorized') expect(hookCalled).toBe(false) }) }) diff --git a/cli/src/claude/utils/startHookServer.ts b/cli/src/claude/utils/startHookServer.ts index 02073edfe..f85db77cc 100644 --- a/cli/src/claude/utils/startHookServer.ts +++ b/cli/src/claude/utils/startHookServer.ts @@ -46,91 +46,106 @@ function readHookToken(req: IncomingMessage): string | null { return header ?? null; } -/** - * Start a dedicated HTTP server for receiving Claude session hooks. - */ -export async function startHookServer(options: HookServerOptions): Promise { - const { onSessionHook } = options; - const hookToken = options.token || randomBytes(16).toString('hex'); +export function createHookRequestHandler(options: { + onSessionHook: (sessionId: string, data: SessionHookData) => void; + token: string; +}): (req: IncomingMessage, res: ServerResponse) => Promise { + const { onSessionHook, token: hookToken } = options; + + return async (req: IncomingMessage, res: ServerResponse): Promise => { + const requestPath = req.url?.split('?')[0]; + if (req.method === 'POST' && requestPath === '/hook/session-start') { + const providedToken = readHookToken(req); + if (providedToken !== hookToken) { + logger.debug('[hookServer] Unauthorized hook request'); + res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized'); + req.resume(); + return; + } - return new Promise((resolve, reject) => { - const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - const requestPath = req.url?.split('?')[0]; - if (req.method === 'POST' && requestPath === '/hook/session-start') { - const providedToken = readHookToken(req); - if (providedToken !== hookToken) { - logger.debug('[hookServer] Unauthorized hook request'); - res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized'); - req.resume(); - return; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + if (!res.headersSent) { + logger.debug('[hookServer] Request timeout'); + res.writeHead(408).end('timeout'); } + req.destroy(new Error('Request timeout')); + }, 5000); - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - if (!res.headersSent) { - logger.debug('[hookServer] Request timeout'); - res.writeHead(408).end('timeout'); - } - req.destroy(new Error('Request timeout')); - }, 5000); + try { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + clearTimeout(timeout); - try { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(chunk as Buffer); - } - clearTimeout(timeout); + if (timedOut || res.headersSent || res.writableEnded) { + return; + } - if (timedOut || res.headersSent || res.writableEnded) { - return; - } + const body = Buffer.concat(chunks).toString('utf-8'); + logger.debug('[hookServer] Received session hook:', body); - const body = Buffer.concat(chunks).toString('utf-8'); - logger.debug('[hookServer] Received session hook:', body); - - let data: SessionHookData = {}; - try { - const parsed = JSON.parse(body); - if (!parsed || typeof parsed !== 'object') { - logger.debug('[hookServer] Parsed hook data is not an object'); - res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); - return; - } - data = parsed as SessionHookData; - } catch (parseError) { - logger.debug('[hookServer] Failed to parse hook data as JSON:', parseError); + let data: SessionHookData = {}; + try { + const parsed = JSON.parse(body); + if (!parsed || typeof parsed !== 'object') { + logger.debug('[hookServer] Parsed hook data is not an object'); res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); return; } + data = parsed as SessionHookData; + } catch (parseError) { + logger.debug('[hookServer] Failed to parse hook data as JSON:', parseError); + res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); + return; + } - const sessionId = data.session_id || data.sessionId; - if (sessionId) { - logger.debug(`[hookServer] Session hook received session ID: ${sessionId}`); - onSessionHook(sessionId, data); - } else { - logger.debug('[hookServer] Session hook received but no session_id found in data'); - res.writeHead(422, { 'Content-Type': 'text/plain' }).end('missing session_id'); - return; - } + const sessionId = data.session_id || data.sessionId; + if (sessionId) { + logger.debug(`[hookServer] Session hook received session ID: ${sessionId}`); + onSessionHook(sessionId, data); + } else { + logger.debug('[hookServer] Session hook received but no session_id found in data'); + res.writeHead(422, { 'Content-Type': 'text/plain' }).end('missing session_id'); + return; + } - if (!res.headersSent && !res.writableEnded) { - res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); - } - } catch (error) { - clearTimeout(timeout); - if (timedOut) { - return; - } - logger.debug('[hookServer] Error handling session hook:', error); - if (!res.headersSent && !res.writableEnded) { - res.writeHead(500).end('error'); - } + if (!res.headersSent && !res.writableEnded) { + res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); + } + } catch (error) { + clearTimeout(timeout); + if (timedOut) { + return; + } + logger.debug('[hookServer] Error handling session hook:', error); + if (!res.headersSent && !res.writableEnded) { + res.writeHead(500).end('error'); } - return; } + return; + } - res.writeHead(404).end('not found'); + res.writeHead(404).end('not found'); + }; +} + +/** + * Start a dedicated HTTP server for receiving Claude session hooks. + */ +export async function startHookServer(options: HookServerOptions): Promise { + const { onSessionHook } = options; + const hookToken = options.token || randomBytes(16).toString('hex'); + const handleRequest = createHookRequestHandler({ + onSessionHook, + token: hookToken + }); + + return new Promise((resolve, reject) => { + const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { + void handleRequest(req, res); }); server.listen(0, '127.0.0.1', () => { diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index fdb7fcf6b..1f45130b0 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -144,3 +144,30 @@ export interface TurnInterruptResponse { ok: boolean; [key: string]: unknown; } + +export interface McpElicitationFormRequest { + mode: 'form'; + message: string; + requestedSchema: Record; + _meta?: Record; +} + +export interface McpElicitationUrlRequest { + mode: 'url'; + message: string; + url: string; + elicitationId: string; + _meta?: Record; +} + +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..35823093e 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,181 @@ 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' } + } + }, + _meta: { + tool_title: 'Demo Tool', + tool_description: 'Collect a token' + } + } + })); + + 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', + _meta: { + tool_title: 'Demo Tool', + tool_description: 'Collect a token' + } + }); + + 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'); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index be648d65d..4e4bf73a1 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,109 @@ 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 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 meta = asRecord(requestRecord._meta); + + 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, + _meta: meta ?? 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 +619,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { registerAppServerPermissionHandlers({ client: appServerClient, - permissionHandler + permissionHandler, + onMcpElicitationRequest: handleMcpElicitationRequest }); appServerClient.setNotificationHandler((method, params) => { @@ -513,6 +638,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 +854,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/cli/src/opencode/utils/startOpencodeHookServer.test.ts b/cli/src/opencode/utils/startOpencodeHookServer.test.ts index 1315fc23a..bfed288b6 100644 --- a/cli/src/opencode/utils/startOpencodeHookServer.test.ts +++ b/cli/src/opencode/utils/startOpencodeHookServer.test.ts @@ -1,65 +1,100 @@ import { describe, it, expect } from 'vitest' -import { request } from 'node:http' -import { startOpencodeHookServer } from './startOpencodeHookServer' +import { Readable } from 'node:stream' +import { createOpencodeHookRequestHandler } from './startOpencodeHookServer' + +class MockRequest extends Readable { + headers: Record + method: string + url: string + private sent = false + + constructor(opts: { + body: string + path?: string + token?: string + }) { + super() + this.method = 'POST' + this.url = opts.path ?? '/hook/opencode' + this.headers = { + 'content-type': 'application/json', + 'content-length': `${Buffer.byteLength(opts.body)}` + } + if (opts.token) { + this.headers['x-hapi-hook-token'] = opts.token + } + this.body = opts.body + } + + private readonly body: string + + _read(): void { + if (this.sent) { + this.push(null) + return + } + this.sent = true + this.push(Buffer.from(this.body, 'utf-8')) + this.push(null) + } +} + +class MockResponse { + statusCode: number | undefined + headersSent = false + writableEnded = false + body = '' + + writeHead(statusCode: number): this { + this.statusCode = statusCode + this.headersSent = true + return this + } + + end(body?: string): this { + if (body) { + this.body += body + } + this.writableEnded = true + return this + } +} const sendHookRequest = async ( - port: number, + handler: ReturnType, body: string, token?: string ): Promise<{ statusCode?: number; body: string }> => { - return await new Promise((resolve, reject) => { - const headers: Record = { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body) - } - if (token) { - headers['x-hapi-hook-token'] = token - } + const req = new MockRequest({ body, token }) + const res = new MockResponse() - const req = request({ - host: '127.0.0.1', - port, - path: '/hook/opencode', - method: 'POST', - headers - }, (res) => { - const chunks: Buffer[] = [] - res.on('data', (chunk) => chunks.push(chunk as Buffer)) - res.on('error', reject) - res.on('end', () => { - resolve({ - statusCode: res.statusCode, - body: Buffer.concat(chunks).toString('utf-8') - }) - }) - }) + await handler(req as never, res as never) - req.on('error', reject) - req.end(body) - }) + return { + statusCode: res.statusCode, + body: res.body + } } describe('startOpencodeHookServer', () => { it('forwards hook payload to callback', async () => { let received: { event?: string; payload?: unknown; sessionId?: string } = {} - const server = await startOpencodeHookServer({ + const token = 'test-hook-token' + const handler = createOpencodeHookRequestHandler({ + token, onEvent: (event) => { received = event } }) - try { - const body = JSON.stringify({ - event: 'message.updated', - payload: { message: 'ok' }, - sessionId: 'session-123' - }) - const response = await sendHookRequest(server.port, body, server.token) - expect(response.statusCode).toBe(200) - } finally { - server.stop() - } + const body = JSON.stringify({ + event: 'message.updated', + payload: { message: 'ok' }, + sessionId: 'session-123' + }) + const response = await sendHookRequest(handler, body, token) + expect(response.statusCode).toBe(200) expect(received.event).toBe('message.updated') expect(received.sessionId).toBe('session-123') expect(received.payload).toEqual({ message: 'ok' }) @@ -67,60 +102,54 @@ describe('startOpencodeHookServer', () => { it('returns 400 for invalid JSON payloads', async () => { let hookCalled = false - const server = await startOpencodeHookServer({ + const token = 'test-hook-token' + const handler = createOpencodeHookRequestHandler({ + token, onEvent: () => { hookCalled = true } }) - try { - const response = await sendHookRequest(server.port, '{"event":', server.token) - expect(response.statusCode).toBe(400) - expect(response.body).toBe('invalid json') - } finally { - server.stop() - } + const response = await sendHookRequest(handler, '{"event":', token) + expect(response.statusCode).toBe(400) + expect(response.body).toBe('invalid json') expect(hookCalled).toBe(false) }) it('returns 422 when event is missing', async () => { let hookCalled = false - const server = await startOpencodeHookServer({ + const token = 'test-hook-token' + const handler = createOpencodeHookRequestHandler({ + token, onEvent: () => { hookCalled = true } }) - try { - const body = JSON.stringify({ payload: { ok: true } }) - const response = await sendHookRequest(server.port, body, server.token) - expect(response.statusCode).toBe(422) - expect(response.body).toBe('missing event') - } finally { - server.stop() - } + const body = JSON.stringify({ payload: { ok: true } }) + const response = await sendHookRequest(handler, body, token) + expect(response.statusCode).toBe(422) + expect(response.body).toBe('missing event') expect(hookCalled).toBe(false) }) it('returns 401 when hook token is missing', async () => { let hookCalled = false - const server = await startOpencodeHookServer({ + const token = 'test-hook-token' + const handler = createOpencodeHookRequestHandler({ + token, onEvent: () => { hookCalled = true } }) - try { - const body = JSON.stringify({ event: 'message.updated', payload: { ok: true } }) - const response = await sendHookRequest(server.port, body) - expect(response.statusCode).toBe(401) - expect(response.body).toBe('unauthorized') - } finally { - server.stop() - } + const body = JSON.stringify({ event: 'message.updated', payload: { ok: true } }) + const response = await sendHookRequest(handler, body) + expect(response.statusCode).toBe(401) + expect(response.body).toBe('unauthorized') expect(hookCalled).toBe(false) }) }) diff --git a/cli/src/opencode/utils/startOpencodeHookServer.ts b/cli/src/opencode/utils/startOpencodeHookServer.ts index cc056135d..2c23ca9e4 100644 --- a/cli/src/opencode/utils/startOpencodeHookServer.ts +++ b/cli/src/opencode/utils/startOpencodeHookServer.ts @@ -22,87 +22,102 @@ function readHookToken(req: IncomingMessage): string | null { return header ?? null; } -export async function startOpencodeHookServer(options: OpencodeHookServerOptions): Promise { - const hookToken = options.token || randomBytes(16).toString('hex'); +export function createOpencodeHookRequestHandler(options: { + onEvent: (event: OpencodeHookEvent) => void; + token: string; +}): (req: IncomingMessage, res: ServerResponse) => Promise { + const { onEvent, token: hookToken } = options; + + return async (req: IncomingMessage, res: ServerResponse): Promise => { + const requestPath = req.url?.split('?')[0]; + if (req.method === 'POST' && requestPath === '/hook/opencode') { + const providedToken = readHookToken(req); + if (providedToken !== hookToken) { + logger.debug('[opencode-hook] Unauthorized hook request'); + res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized'); + req.resume(); + return; + } - return new Promise((resolve, reject) => { - const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - const requestPath = req.url?.split('?')[0]; - if (req.method === 'POST' && requestPath === '/hook/opencode') { - const providedToken = readHookToken(req); - if (providedToken !== hookToken) { - logger.debug('[opencode-hook] Unauthorized hook request'); - res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized'); - req.resume(); - return; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + if (!res.headersSent) { + logger.debug('[opencode-hook] Request timeout'); + res.writeHead(408).end('timeout'); } + req.destroy(new Error('Request timeout')); + }, 5000); - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - if (!res.headersSent) { - logger.debug('[opencode-hook] Request timeout'); - res.writeHead(408).end('timeout'); - } - req.destroy(new Error('Request timeout')); - }, 5000); + try { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + clearTimeout(timeout); - try { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(chunk as Buffer); - } - clearTimeout(timeout); + if (timedOut || res.headersSent || res.writableEnded) { + return; + } - if (timedOut || res.headersSent || res.writableEnded) { - return; - } + const body = Buffer.concat(chunks).toString('utf-8'); + logger.debug('[opencode-hook] Received hook:', body); - const body = Buffer.concat(chunks).toString('utf-8'); - logger.debug('[opencode-hook] Received hook:', body); - - let data: Record = {}; - try { - const parsed = JSON.parse(body); - if (!parsed || typeof parsed !== 'object') { - logger.debug('[opencode-hook] Parsed hook data is not an object'); - res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); - return; - } - data = parsed as Record; - } catch (parseError) { - logger.debug('[opencode-hook] Failed to parse hook data as JSON:', parseError); + let data: Record = {}; + try { + const parsed = JSON.parse(body); + if (!parsed || typeof parsed !== 'object') { + logger.debug('[opencode-hook] Parsed hook data is not an object'); res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); return; } + data = parsed as Record; + } catch (parseError) { + logger.debug('[opencode-hook] Failed to parse hook data as JSON:', parseError); + res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); + return; + } - const eventValue = data.event; - if (typeof eventValue !== 'string' || eventValue.length === 0) { - res.writeHead(422, { 'Content-Type': 'text/plain' }).end('missing event'); - return; - } + const eventValue = data.event; + if (typeof eventValue !== 'string' || eventValue.length === 0) { + res.writeHead(422, { 'Content-Type': 'text/plain' }).end('missing event'); + return; + } - const payload = data.payload; - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; - options.onEvent({ event: eventValue, payload, sessionId }); + const payload = data.payload; + const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; + onEvent({ event: eventValue, payload, sessionId }); - if (!res.headersSent && !res.writableEnded) { - res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); - } - } catch (error) { - clearTimeout(timeout); - if (timedOut) { - return; - } - logger.debug('[opencode-hook] Error handling hook:', error); - if (!res.headersSent && !res.writableEnded) { - res.writeHead(500).end('error'); - } + if (!res.headersSent && !res.writableEnded) { + res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); + } + } catch (error) { + clearTimeout(timeout); + if (timedOut) { + return; + } + logger.debug('[opencode-hook] Error handling hook:', error); + if (!res.headersSent && !res.writableEnded) { + res.writeHead(500).end('error'); } - return; } + return; + } - res.writeHead(404).end('not found'); + res.writeHead(404).end('not found'); + }; +} + +export async function startOpencodeHookServer(options: OpencodeHookServerOptions): Promise { + const hookToken = options.token || randomBytes(16).toString('hex'); + const handleRequest = createOpencodeHookRequestHandler({ + onEvent: options.onEvent, + token: hookToken + }); + + return new Promise((resolve, reject) => { + const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { + void handleRequest(req, res); }); server.listen(0, '127.0.0.1', () => { 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..fbd72e98e --- /dev/null +++ b/web/src/components/ToolCard/CodexMcpElicitationFooter.tsx @@ -0,0 +1,259 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ApiClient } from '@/api/client' +import type { ChatToolCall } from '@/chat/types' +import { Spinner } from '@/components/Spinner' +import { + buildCodexMcpElicitationFormContent, + createCodexMcpElicitationFormState, + isCodexMcpElicitationToolName, + normalizeCodexMcpElicitationFormSchema, + parseCodexMcpElicitationInput, + type CodexMcpElicitationFormField +} 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 formSchema = useMemo(() => { + if (!parsed || parsed.mode !== 'form') { + return null + } + return normalizeCodexMcpElicitationFormSchema(parsed.requestedSchema) + }, [parsed]) + const [loading, setLoading] = useState<'accept' | 'decline' | null>(null) + const [error, setError] = useState(null) + const [formState, setFormState] = useState>({}) + + useEffect(() => { + setLoading(null) + setError(null) + setFormState(formSchema ? createCodexMcpElicitationFormState(formSchema) : {}) + }, [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 updateField = (key: string, value: string | boolean | null) => { + setError(null) + setFormState((prev) => ({ + ...prev, + [key]: value + })) + } + + const submitAccept = async () => { + if (loading) return + + let content: unknown | null = null + if (parsed.mode === 'form') { + if (!formSchema) { + haptic.notification('error') + setError('This MCP request requires a form schema before it can be submitted') + return + } + + const submission = buildCodexMcpElicitationFormContent(formSchema, formState) + if (!submission.ok) { + haptic.notification('error') + setError(submission.error) + return + } + + content = submission.content + } + if (parsed.mode === 'url') { + window.open(parsed.url, '_blank', 'noopener,noreferrer') + } + + setLoading('accept') + try { + await run(() => props.api.respondToMcpElicitation(props.sessionId, parsed.requestId, { + action: 'accept', + content + })) + } finally { + setLoading(null) + } + } + + const submitDecline = async () => { + if (loading) return + setLoading('decline') + try { + await run(() => props.api.respondToMcpElicitation(props.sessionId, parsed.requestId, { + action: 'decline', + content: null + })) + } finally { + setLoading(null) + } + } + + const renderField = (field: CodexMcpElicitationFormField) => { + const fieldId = `codex-mcp-elicitation-${parsed.requestId}-${field.key}` + const commonClassName = 'w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm text-[var(--app-fg)] focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] disabled:opacity-50' + const draftValue = formState[field.key] + + return ( +
+ + {field.description ? ( +
+ {field.description} +
+ ) : null} + + {field.kind === 'boolean' ? ( +
+ updateField(field.key, e.target.checked)} + /> +
+ ) : field.kind === 'enum' ? ( + + ) : field.kind === 'json' ? ( +