elicitationHandler.ts
1 import type { Client } from '@modelcontextprotocol/sdk/client/index.js' 2 import { 3 ElicitationCompleteNotificationSchema, 4 type ElicitRequestParams, 5 ElicitRequestSchema, 6 type ElicitResult, 7 } from '@modelcontextprotocol/sdk/types.js' 8 import type { AppState } from '../../state/AppState.js' 9 import { 10 executeElicitationHooks, 11 executeElicitationResultHooks, 12 executeNotificationHooks, 13 } from '../../utils/hooks.js' 14 import { logMCPDebug, logMCPError } from '../../utils/log.js' 15 import { jsonStringify } from '../../utils/slowOperations.js' 16 import { 17 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 18 logEvent, 19 } from '../analytics/index.js' 20 21 /** Configuration for the waiting state shown after the user opens a URL. */ 22 export type ElicitationWaitingState = { 23 /** Button label, e.g. "Retry now" or "Skip confirmation" */ 24 actionLabel: string 25 /** Whether to show a visible Cancel button (e.g. for error-based retry flow) */ 26 showCancel?: boolean 27 } 28 29 export type ElicitationRequestEvent = { 30 serverName: string 31 /** The JSON-RPC request ID, unique per server connection. */ 32 requestId: string | number 33 params: ElicitRequestParams 34 signal: AbortSignal 35 /** 36 * Resolves the elicitation. For explicit elicitations, all actions are 37 * meaningful. For error-based retry (-32042), 'accept' is a no-op — 38 * the retry is driven by onWaitingDismiss instead. 39 */ 40 respond: (response: ElicitResult) => void 41 /** For URL elicitations: shown after user opens the browser. */ 42 waitingState?: ElicitationWaitingState 43 /** Called when phase 2 (waiting) is dismissed by user action or completion. */ 44 onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void 45 /** Set to true by the completion notification handler when the server confirms completion. */ 46 completed?: boolean 47 } 48 49 function getElicitationMode(params: ElicitRequestParams): 'form' | 'url' { 50 return params.mode === 'url' ? 'url' : 'form' 51 } 52 53 /** Find a queued elicitation event by server name and elicitationId. */ 54 function findElicitationInQueue( 55 queue: ElicitationRequestEvent[], 56 serverName: string, 57 elicitationId: string, 58 ): number { 59 return queue.findIndex( 60 e => 61 e.serverName === serverName && 62 e.params.mode === 'url' && 63 'elicitationId' in e.params && 64 e.params.elicitationId === elicitationId, 65 ) 66 } 67 68 export function registerElicitationHandler( 69 client: Client, 70 serverName: string, 71 setAppState: (f: (prevState: AppState) => AppState) => void, 72 ): void { 73 // Register the elicitation request handler. 74 // Wrapped in try/catch because setRequestHandler throws if the client wasn't 75 // created with elicitation capability declared. 76 try { 77 client.setRequestHandler(ElicitRequestSchema, async (request, extra) => { 78 logMCPDebug( 79 serverName, 80 `Received elicitation request: ${jsonStringify(request)}`, 81 ) 82 83 const mode = getElicitationMode(request.params) 84 85 logEvent('tengu_mcp_elicitation_shown', { 86 mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 87 }) 88 89 try { 90 // Run elicitation hooks first - they can provide a response programmatically 91 const hookResponse = await runElicitationHooks( 92 serverName, 93 request.params, 94 extra.signal, 95 ) 96 if (hookResponse) { 97 logMCPDebug( 98 serverName, 99 `Elicitation resolved by hook: ${jsonStringify(hookResponse)}`, 100 ) 101 logEvent('tengu_mcp_elicitation_response', { 102 mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 103 action: 104 hookResponse.action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 105 }) 106 return hookResponse 107 } 108 109 const elicitationId = 110 mode === 'url' && 'elicitationId' in request.params 111 ? (request.params.elicitationId as string | undefined) 112 : undefined 113 114 const response = new Promise<ElicitResult>(resolve => { 115 const onAbort = () => { 116 resolve({ action: 'cancel' }) 117 } 118 119 if (extra.signal.aborted) { 120 onAbort() 121 return 122 } 123 124 const waitingState: ElicitationWaitingState | undefined = 125 elicitationId ? { actionLabel: 'Skip confirmation' } : undefined 126 127 setAppState(prev => ({ 128 ...prev, 129 elicitation: { 130 queue: [ 131 ...prev.elicitation.queue, 132 { 133 serverName, 134 requestId: extra.requestId, 135 params: request.params, 136 signal: extra.signal, 137 waitingState, 138 respond: (result: ElicitResult) => { 139 extra.signal.removeEventListener('abort', onAbort) 140 logEvent('tengu_mcp_elicitation_response', { 141 mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 142 action: 143 result.action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 144 }) 145 resolve(result) 146 }, 147 }, 148 ], 149 }, 150 })) 151 152 extra.signal.addEventListener('abort', onAbort, { once: true }) 153 }) 154 const rawResult = await response 155 logMCPDebug( 156 serverName, 157 `Elicitation response: ${jsonStringify(rawResult)}`, 158 ) 159 const result = await runElicitationResultHooks( 160 serverName, 161 rawResult, 162 extra.signal, 163 mode, 164 elicitationId, 165 ) 166 return result 167 } catch (error) { 168 logMCPError(serverName, `Elicitation error: ${error}`) 169 return { action: 'cancel' as const } 170 } 171 }) 172 173 // Register handler for elicitation completion notifications (URL mode). 174 // Sets `completed: true` on the matching queue event; the dialog reacts to this flag. 175 client.setNotificationHandler( 176 ElicitationCompleteNotificationSchema, 177 notification => { 178 const { elicitationId } = notification.params 179 logMCPDebug( 180 serverName, 181 `Received elicitation completion notification: ${elicitationId}`, 182 ) 183 void executeNotificationHooks({ 184 message: `MCP server "${serverName}" confirmed elicitation ${elicitationId} complete`, 185 notificationType: 'elicitation_complete', 186 }) 187 let found = false 188 setAppState(prev => { 189 const idx = findElicitationInQueue( 190 prev.elicitation.queue, 191 serverName, 192 elicitationId, 193 ) 194 if (idx === -1) return prev 195 found = true 196 const queue = [...prev.elicitation.queue] 197 queue[idx] = { ...queue[idx]!, completed: true } 198 return { ...prev, elicitation: { queue } } 199 }) 200 if (!found) { 201 logMCPDebug( 202 serverName, 203 `Ignoring completion notification for unknown elicitation: ${elicitationId}`, 204 ) 205 } 206 }, 207 ) 208 } catch { 209 // Client wasn't created with elicitation capability - nothing to register 210 return 211 } 212 } 213 214 export async function runElicitationHooks( 215 serverName: string, 216 params: ElicitRequestParams, 217 signal: AbortSignal, 218 ): Promise<ElicitResult | undefined> { 219 try { 220 const mode = params.mode === 'url' ? 'url' : 'form' 221 const url = 'url' in params ? (params.url as string) : undefined 222 const elicitationId = 223 'elicitationId' in params 224 ? (params.elicitationId as string | undefined) 225 : undefined 226 227 const { elicitationResponse, blockingError } = 228 await executeElicitationHooks({ 229 serverName, 230 message: params.message, 231 requestedSchema: 232 'requestedSchema' in params 233 ? (params.requestedSchema as Record<string, unknown>) 234 : undefined, 235 signal, 236 mode, 237 url, 238 elicitationId, 239 }) 240 241 if (blockingError) { 242 return { action: 'decline' } 243 } 244 245 if (elicitationResponse) { 246 return { 247 action: elicitationResponse.action, 248 content: elicitationResponse.content, 249 } 250 } 251 252 return undefined 253 } catch (error) { 254 logMCPError(serverName, `Elicitation hook error: ${error}`) 255 return undefined 256 } 257 } 258 259 /** 260 * Run ElicitationResult hooks after the user has responded, then fire a 261 * `elicitation_response` notification. Returns a (potentially modified) 262 * ElicitResult — hooks may override the action/content or block the response. 263 */ 264 export async function runElicitationResultHooks( 265 serverName: string, 266 result: ElicitResult, 267 signal: AbortSignal, 268 mode?: 'form' | 'url', 269 elicitationId?: string, 270 ): Promise<ElicitResult> { 271 try { 272 const { elicitationResultResponse, blockingError } = 273 await executeElicitationResultHooks({ 274 serverName, 275 action: result.action, 276 content: result.content as Record<string, unknown> | undefined, 277 signal, 278 mode, 279 elicitationId, 280 }) 281 282 if (blockingError) { 283 void executeNotificationHooks({ 284 message: `Elicitation response for server "${serverName}": decline`, 285 notificationType: 'elicitation_response', 286 }) 287 return { action: 'decline' } 288 } 289 290 const finalResult = elicitationResultResponse 291 ? { 292 action: elicitationResultResponse.action, 293 content: elicitationResultResponse.content ?? result.content, 294 } 295 : result 296 297 // Fire a notification for observability 298 void executeNotificationHooks({ 299 message: `Elicitation response for server "${serverName}": ${finalResult.action}`, 300 notificationType: 'elicitation_response', 301 }) 302 303 return finalResult 304 } catch (error) { 305 logMCPError(serverName, `ElicitationResult hook error: ${error}`) 306 // Fire notification even on error 307 void executeNotificationHooks({ 308 message: `Elicitation response for server "${serverName}": ${result.action}`, 309 notificationType: 'elicitation_response', 310 }) 311 return result 312 } 313 }