| import type { Client } from '@modelcontextprotocol/sdk/client/index.js' |
| import { |
| ElicitationCompleteNotificationSchema, |
| type ElicitRequestParams, |
| ElicitRequestSchema, |
| type ElicitResult, |
| } from '@modelcontextprotocol/sdk/types.js' |
| import type { AppState } from '../../state/AppState.js' |
| import { |
| executeElicitationHooks, |
| executeElicitationResultHooks, |
| executeNotificationHooks, |
| } from '../../utils/hooks.js' |
| import { logMCPDebug, logMCPError } from '../../utils/log.js' |
| import { jsonStringify } from '../../utils/slowOperations.js' |
| import { |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| logEvent, |
| } from '../analytics/index.js' |
|
|
| |
| export type ElicitationWaitingState = { |
| |
| actionLabel: string |
| |
| showCancel?: boolean |
| } |
|
|
| export type ElicitationRequestEvent = { |
| serverName: string |
| |
| requestId: string | number |
| params: ElicitRequestParams |
| signal: AbortSignal |
| |
| |
| |
| |
| |
| respond: (response: ElicitResult) => void |
| |
| waitingState?: ElicitationWaitingState |
| |
| onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void |
| |
| completed?: boolean |
| } |
|
|
| function getElicitationMode(params: ElicitRequestParams): 'form' | 'url' { |
| return params.mode === 'url' ? 'url' : 'form' |
| } |
|
|
| |
| function findElicitationInQueue( |
| queue: ElicitationRequestEvent[], |
| serverName: string, |
| elicitationId: string, |
| ): number { |
| return queue.findIndex( |
| e => |
| e.serverName === serverName && |
| e.params.mode === 'url' && |
| 'elicitationId' in e.params && |
| e.params.elicitationId === elicitationId, |
| ) |
| } |
|
|
| export function registerElicitationHandler( |
| client: Client, |
| serverName: string, |
| setAppState: (f: (prevState: AppState) => AppState) => void, |
| ): void { |
| |
| |
| |
| try { |
| client.setRequestHandler(ElicitRequestSchema, async (request, extra) => { |
| logMCPDebug( |
| serverName, |
| `Received elicitation request: ${jsonStringify(request)}`, |
| ) |
|
|
| const mode = getElicitationMode(request.params) |
|
|
| logEvent('tengu_mcp_elicitation_shown', { |
| mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| }) |
|
|
| try { |
| |
| const hookResponse = await runElicitationHooks( |
| serverName, |
| request.params, |
| extra.signal, |
| ) |
| if (hookResponse) { |
| logMCPDebug( |
| serverName, |
| `Elicitation resolved by hook: ${jsonStringify(hookResponse)}`, |
| ) |
| logEvent('tengu_mcp_elicitation_response', { |
| mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| action: |
| hookResponse.action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| }) |
| return hookResponse |
| } |
|
|
| const elicitationId = |
| mode === 'url' && 'elicitationId' in request.params |
| ? (request.params.elicitationId as string | undefined) |
| : undefined |
|
|
| const response = new Promise<ElicitResult>(resolve => { |
| const onAbort = () => { |
| resolve({ action: 'cancel' }) |
| } |
|
|
| if (extra.signal.aborted) { |
| onAbort() |
| return |
| } |
|
|
| const waitingState: ElicitationWaitingState | undefined = |
| elicitationId ? { actionLabel: 'Skip confirmation' } : undefined |
|
|
| setAppState(prev => ({ |
| ...prev, |
| elicitation: { |
| queue: [ |
| ...prev.elicitation.queue, |
| { |
| serverName, |
| requestId: extra.requestId, |
| params: request.params, |
| signal: extra.signal, |
| waitingState, |
| respond: (result: ElicitResult) => { |
| extra.signal.removeEventListener('abort', onAbort) |
| logEvent('tengu_mcp_elicitation_response', { |
| mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| action: |
| result.action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| }) |
| resolve(result) |
| }, |
| }, |
| ], |
| }, |
| })) |
|
|
| extra.signal.addEventListener('abort', onAbort, { once: true }) |
| }) |
| const rawResult = await response |
| logMCPDebug( |
| serverName, |
| `Elicitation response: ${jsonStringify(rawResult)}`, |
| ) |
| const result = await runElicitationResultHooks( |
| serverName, |
| rawResult, |
| extra.signal, |
| mode, |
| elicitationId, |
| ) |
| return result |
| } catch (error) { |
| logMCPError(serverName, `Elicitation error: ${error}`) |
| return { action: 'cancel' as const } |
| } |
| }) |
|
|
| |
| |
| client.setNotificationHandler( |
| ElicitationCompleteNotificationSchema, |
| notification => { |
| const { elicitationId } = notification.params |
| logMCPDebug( |
| serverName, |
| `Received elicitation completion notification: ${elicitationId}`, |
| ) |
| void executeNotificationHooks({ |
| message: `MCP server "${serverName}" confirmed elicitation ${elicitationId} complete`, |
| notificationType: 'elicitation_complete', |
| }) |
| let found = false |
| setAppState(prev => { |
| const idx = findElicitationInQueue( |
| prev.elicitation.queue, |
| serverName, |
| elicitationId, |
| ) |
| if (idx === -1) return prev |
| found = true |
| const queue = [...prev.elicitation.queue] |
| queue[idx] = { ...queue[idx]!, completed: true } |
| return { ...prev, elicitation: { queue } } |
| }) |
| if (!found) { |
| logMCPDebug( |
| serverName, |
| `Ignoring completion notification for unknown elicitation: ${elicitationId}`, |
| ) |
| } |
| }, |
| ) |
| } catch { |
| |
| return |
| } |
| } |
|
|
| export async function runElicitationHooks( |
| serverName: string, |
| params: ElicitRequestParams, |
| signal: AbortSignal, |
| ): Promise<ElicitResult | undefined> { |
| try { |
| const mode = params.mode === 'url' ? 'url' : 'form' |
| const url = 'url' in params ? (params.url as string) : undefined |
| const elicitationId = |
| 'elicitationId' in params |
| ? (params.elicitationId as string | undefined) |
| : undefined |
|
|
| const { elicitationResponse, blockingError } = |
| await executeElicitationHooks({ |
| serverName, |
| message: params.message, |
| requestedSchema: |
| 'requestedSchema' in params |
| ? (params.requestedSchema as Record<string, unknown>) |
| : undefined, |
| signal, |
| mode, |
| url, |
| elicitationId, |
| }) |
|
|
| if (blockingError) { |
| return { action: 'decline' } |
| } |
|
|
| if (elicitationResponse) { |
| return { |
| action: elicitationResponse.action, |
| content: elicitationResponse.content, |
| } |
| } |
|
|
| return undefined |
| } catch (error) { |
| logMCPError(serverName, `Elicitation hook error: ${error}`) |
| return undefined |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export async function runElicitationResultHooks( |
| serverName: string, |
| result: ElicitResult, |
| signal: AbortSignal, |
| mode?: 'form' | 'url', |
| elicitationId?: string, |
| ): Promise<ElicitResult> { |
| try { |
| const { elicitationResultResponse, blockingError } = |
| await executeElicitationResultHooks({ |
| serverName, |
| action: result.action, |
| content: result.content as Record<string, unknown> | undefined, |
| signal, |
| mode, |
| elicitationId, |
| }) |
|
|
| if (blockingError) { |
| void executeNotificationHooks({ |
| message: `Elicitation response for server "${serverName}": decline`, |
| notificationType: 'elicitation_response', |
| }) |
| return { action: 'decline' } |
| } |
|
|
| const finalResult = elicitationResultResponse |
| ? { |
| action: elicitationResultResponse.action, |
| content: elicitationResultResponse.content ?? result.content, |
| } |
| : result |
|
|
| |
| void executeNotificationHooks({ |
| message: `Elicitation response for server "${serverName}": ${finalResult.action}`, |
| notificationType: 'elicitation_response', |
| }) |
|
|
| return finalResult |
| } catch (error) { |
| logMCPError(serverName, `ElicitationResult hook error: ${error}`) |
| |
| void executeNotificationHooks({ |
| message: `Elicitation response for server "${serverName}": ${result.action}`, |
| notificationType: 'elicitation_response', |
| }) |
| return result |
| } |
| } |
|
|