diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 1637b8f7e19..07fc0137de5 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -22,6 +22,7 @@ const { generateCheckAccess, validateOAuthSession, OAUTH_SESSION_COOKIE, + parseElicitationFlowId, } = require('@librechat/api'); const { createMCPServerController, @@ -68,6 +69,24 @@ const canAccessOAuthFlow = (flowId, userId) => { return parsed.userId === userId || parsed.userId === 'system'; }; +/** + * Elicitation flow IDs embed the requesting userId directly (unlike OAuth flow + * IDs, which are one-per-server; elicitation flows are one-per-tool-invocation + * — see `generateElicitationFlowId` in `@librechat/api`). This enforces the + * same per-user ownership OAuth flow routes do, so one user can't complete or + * observe another user's pending elicitation via a guessed/observed flowId. + */ +const canAccessElicitationFlow = (flowId, userId) => { + const parsed = parseElicitationFlowId(flowId); + if (!parsed) { + return false; + } + if (parsed.tenantId && parsed.tenantId !== getTenantId()) { + return false; + } + return parsed.userId === userId; +}; + const clearGetTokensFlow = async ({ flowManager, flowId, tokens }) => { const state = await flowManager.getFlowState(flowId, 'mcp_get_tokens'); if (state?.type === 'mcp_get_tokens' && state.status === 'PENDING') { @@ -658,6 +677,46 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => { } }); +/** + * Submit a response to an MCP elicitation request. Completes a pending + * elicitation flow so `MCPManager.callTool` can resume: + * - `action: 'accept' | 'decline' | 'cancel'` — form-mode `elicitation/create` + * response (spec 2025-06-18); `content` carries the submitted field values. + * - `action: 'complete' | 'cancel'` — URL-mode card (either a `mode: 'url'` + * `elicitation/create` request, or a -32042 URL-exception retry): `complete` + * means "I've authorized — continue", which resumes/retries the tool call. + */ +router.post('/elicitation/:flowId', requireJwtAuth, async (req, res) => { + try { + const { flowId } = req.params; + const { action, content } = req.body ?? {}; + const user = req.user; + + if (!user?.id) { + return res.status(401).json({ error: 'User not authenticated' }); + } + + if (!action || !['accept', 'decline', 'cancel', 'complete'].includes(action)) { + return res.status(400).json({ error: 'Invalid action' }); + } + + if (!canAccessElicitationFlow(flowId, user.id)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + const flowsCache = getLogStores(CacheKeys.FLOWS); + const flowManager = getFlowStateManager(flowsCache); + const ok = await flowManager.completeFlow(flowId, 'mcp_elicit', { action, content }); + if (!ok) { + return res.status(404).json({ error: 'Flow not found' }); + } + return res.json({ ok: true }); + } catch (error) { + logger.error('[MCP Elicitation] Failed to complete elicitation flow', error); + return res.status(500).json({ error: 'Failed to complete elicitation flow' }); + } +}); + /** * Reinitialize MCP server * This endpoint allows reinitializing a specific MCP server diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 34164589a0a..90202915d47 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -377,6 +377,33 @@ function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) { }; } +/** + * Emits the `on_elicitation` SSE event so the chat UI can render an + * authorization/form card. Covers both wire mechanisms: a `mode: 'form'|'url'` + * `elicitation/create` request, and the -32042 URL-exception path (always + * `mode: 'url'`, no `requestedSchema`). + * @param {object} params + * @param {ServerResponse} params.res - The Express response object for sending events. + * @param {string} params.stepId - The ID of the step. + * @param {string | null} [params.streamId] - The stream ID for resumable mode. + * @returns {(params: { flowId: string; mode: 'form' | 'url'; message: string; serverName?: string; toolName?: string; requestedSchema?: object; url?: string }) => Promise} + */ +function createElicitationStart({ res, stepId, streamId = null }) { + return async function ({ flowId, mode, message, serverName, toolName, requestedSchema, url }) { + const data = { + id: stepId, + runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID, + elicitation: { flowId, mode, message, serverName, toolName, requestedSchema, url }, + }; + const eventData = { event: 'on_elicitation', data }; + if (streamId) { + await GenerationJobManager.emitChunk(streamId, eventData); + } else { + sendEvent(res, eventData); + } + }; +} + /** * @param {Object} params * @param {ServerResponse} params.res - The Express response object for sending events. @@ -807,6 +834,11 @@ function createToolInstance({ toolCall, streamId, }); + const elicitationStart = createElicitationStart({ + res, + stepId, + streamId, + }); if (derivedSignal) { const tenantId = config?.configurable?.user?.tenantId ?? getTenantId(); @@ -840,6 +872,7 @@ function createToolInstance({ }, oauthStart, oauthEnd, + elicitationStart, graphTokenResolver: getGraphApiToken, oboTokenResolver: exchangeOboToken, oboTrustChecker: createOboTrustChecker(), diff --git a/client/src/components/Chat/Messages/Content/ElicitationForm.tsx b/client/src/components/Chat/Messages/Content/ElicitationForm.tsx new file mode 100644 index 00000000000..0787e410576 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/ElicitationForm.tsx @@ -0,0 +1,511 @@ +import { useState } from 'react'; +import type { ReactNode } from 'react'; +import { dataService } from 'librechat-data-provider'; +import { Button, Input, Label, Spinner } from '@librechat/client'; +import { + ShieldCheck, + ClipboardList, + ExternalLink, + RotateCw, + CheckCircle2, + XCircle, +} from 'lucide-react'; +import type { Agents } from 'librechat-data-provider'; +import type { TranslationKeys } from '~/hooks/useLocalize'; +import { useLocalize } from '~/hooks'; +import cn from '~/utils/cn'; + +type ElicitationAction = Agents.ElicitationAction; + +type ElicitationField = { + key: string; + schema: Agents.ElicitationPropertySchema; +}; + +type FieldValue = string | number | boolean; + +function getDefaultValues( + properties: Record, +): Record { + const defaults: Record = {}; + for (const [key, schema] of Object.entries(properties)) { + if (schema.default != null) { + defaults[key] = schema.default; + } else if (schema.type === 'boolean') { + defaults[key] = false; + } else { + defaults[key] = ''; + } + } + return defaults; +} + +function getStatusText( + resolvedAction: ElicitationAction, + localize: (key: TranslationKeys) => string, +): string { + if (resolvedAction === 'accept') { + return localize('com_ui_elicitation_completed'); + } + if (resolvedAction === 'complete') { + return localize('com_ui_elicitation_authorized'); + } + if (resolvedAction === 'cancel') { + return localize('com_ui_elicitation_cancelled'); + } + return localize('com_ui_elicitation_declined'); +} + +/** Header chrome shared by both modes: a tinted circular icon, a title, and the + * requesting server/tool identity. Keeps the card visually native to LibreChat's + * other in-chat system cards (see `ToolCall` OAuth sign-in). */ +function CardHeader({ + icon, + title, + identity, +}: { + icon: ReactNode; + title: string; + identity?: string; +}) { + return ( +
+
+ {icon} +
+
+

{title}

+ {identity &&

{identity}

} +
+
+ ); +} + +/** Button label with a stable footprint: when `acting`, the label is kept in the + * layout but hidden, and the Spinner is overlaid — so a click never shifts the row. */ +function ActionLabel({ + label, + icon, + acting, +}: { + label: string; + icon?: ReactNode; + acting: boolean; +}) { + return ( + + + {icon} + {label} + + {acting && ( + + + + )} + + ); +} + +/** + * Renders an in-chat card for MCP elicitation. Covers both wire mechanisms: + * - `mode: 'form'` — a 2025-06-18 `elicitation/create` request; renders the + * requested JSON-schema fields and posts `accept`/`decline`. + * - `mode: 'url'` — either a `mode: 'url'` `elicitation/create` request, or the + * -32042 URL-exception path on `tools/call`; renders a message, a prominent + * authorization link, and Continue/Cancel. Continue posts `action: + * 'complete'`, which resumes/retries the suspended tool call server-side. + * + * Both modes resolve via the same `POST /api/mcp/elicitation/:flowId` route + * (`dataService.respondToElicitation`), mirroring the OAuth "visit a URL, then + * get resumed" flow already used elsewhere in MCP tool calls. + */ +export default function ElicitationForm({ + flowId, + mode, + message, + serverName, + toolName, + url, + requestedSchema, + action: initialAction, +}: Agents.ElicitationContent['elicitation']) { + const localize = useLocalize(); + const isUrlMode = mode === 'url'; + const properties = requestedSchema?.properties ?? {}; + const [values, setValues] = useState>(() => + getDefaultValues(properties), + ); + const [pendingAction, setPendingAction] = useState(); + const [sendFailed, setSendFailed] = useState(false); + // Track whether the user has opened this flow's authorization link. When there + // is no link to open, there is nothing to gate on, so treat it as already opened. + const [urlOpened, setUrlOpened] = useState(!url); + const [errors, setErrors] = useState>({}); + const [resolvedAction, setResolvedAction] = useState( + initialAction, + ); + + const submitting = pendingAction != null; + const identity = [serverName, toolName].filter(Boolean).join(' · ') || undefined; + + const fields: ElicitationField[] = Object.entries(properties).map(([key, schema]) => ({ + key, + schema, + })); + + // Form mode: show the server's message, or a fallback so a schema-less request + // never renders bare buttons over blank space. + const formIntro = + message || (fields.length === 0 ? localize('com_ui_elicitation_form_empty') : undefined); + + const validate = (): boolean => { + const newErrors: Record = {}; + for (const { key, schema } of fields) { + const required = requestedSchema?.required?.includes(key) ?? false; + const val = values[key]; + if (required && (val === '' || val == null)) { + newErrors[key] = `${schema.title ?? key} is required`; + } + if (schema.type === 'string' && typeof val === 'string') { + if (schema.minLength != null && val.length < schema.minLength) { + newErrors[key] = `Minimum length is ${schema.minLength}`; + } + if (schema.maxLength != null && val.length > schema.maxLength) { + newErrors[key] = `Maximum length is ${schema.maxLength}`; + } + } + if ((schema.type === 'number' || schema.type === 'integer') && val !== '') { + const num = Number(val); + if (schema.minimum != null && num < schema.minimum) { + newErrors[key] = `Minimum value is ${schema.minimum}`; + } + if (schema.maximum != null && num > schema.maximum) { + newErrors[key] = `Maximum value is ${schema.maximum}`; + } + } + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const submitAction = async (action: ElicitationAction) => { + if (action === 'accept' && !isUrlMode && !validate()) { + return; + } + setSendFailed(false); + setPendingAction(action); + try { + const content = + action === 'accept' && !isUrlMode + ? Object.fromEntries( + fields.map(({ key, schema }) => { + const val = values[key]; + if (schema.type === 'number' || schema.type === 'integer') { + return [key, Number(val)]; + } + return [key, val]; + }), + ) + : undefined; + await dataService.respondToElicitation(flowId, { action, content }); + setResolvedAction(action); + } catch { + // Surface an inline retry affordance; the server-side flow keeps waiting + // (or times out on its own), so the card stays interactive for a retry. + setSendFailed(true); + } finally { + setPendingAction(undefined); + } + }; + + const requiredMark = (required: boolean) => + required ? ( + + ) : null; + + const renderField = ({ key, schema }: ElicitationField) => { + const label = schema.title ?? key; + const fieldId = `elicitation-${flowId}-${key}`; + const error = errors[key]; + const required = requestedSchema?.required?.includes(key) ?? false; + const descId = schema.description ? `${fieldId}-description` : undefined; + const errId = error ? `${fieldId}-error` : undefined; + const describedBy = [descId, errId].filter(Boolean).join(' ') || undefined; + + if (schema.enum) { + return ( +
+ + {schema.description && ( +

+ {schema.description} +

+ )} + + {error && ( +

+ {error} +

+ )} +
+ ); + } + + if (schema.type === 'boolean') { + return ( +
+ {/* Nested label grows the click target past the 16px box toward ~28px. */} + + {schema.description && ( +

+ {schema.description} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); + } + + return ( +
+ + {schema.description && ( +

+ {schema.description} +

+ )} + setValues((prev) => ({ ...prev, [key]: e.target.value }))} + disabled={submitting} + required={required} + aria-required={required || undefined} + aria-invalid={error ? true : undefined} + aria-describedby={describedBy} + min={schema.minimum} + max={schema.maximum} + minLength={schema.minLength} + maxLength={schema.maxLength} + className={cn(error && 'border-border-destructive focus-visible:ring-border-destructive')} + /> + {error && ( +

+ {error} +

+ )} +
+ ); + }; + + if (resolvedAction) { + const succeeded = resolvedAction === 'accept' || resolvedAction === 'complete'; + return ( +
+ {succeeded ? ( +
+ ); + } + + const errorLine = sendFailed ? ( +

+ {localize('com_ui_elicitation_error')} +

+ ) : null; + + if (isUrlMode) { + return ( +
+
+
+
+ ); + } + + return ( +
+
+
+
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 6df833370a8..fdb8a453951 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -7,7 +7,7 @@ import { imageGenTools, isImageVisionTool, } from 'librechat-data-provider'; -import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'; +import type { Agents, TMessageContentParts, TAttachment } from 'librechat-data-provider'; import { ImageGen, ExecuteCode, @@ -23,6 +23,7 @@ import { SubagentCall, } from './Parts'; import { ErrorMessage } from './MessageContent'; +import ElicitationForm from './ElicitationForm'; import RetrievalCall from './RetrievalCall'; import { getCachedPreview } from '~/utils'; import AgentHandoff from './AgentHandoff'; @@ -356,6 +357,12 @@ const Part = memo(function Part({ /> ); } + } else if (part.type === ContentTypes.ELICITATION) { + const elicitation = (part as Agents.ElicitationContent).elicitation; + if (!elicitation) { + return null; + } + return ; } else if (part.type === ContentTypes.IMAGE_FILE) { const imageFile = part[ContentTypes.IMAGE_FILE]; const cached = imageFile.file_id ? getCachedPreview(imageFile.file_id) : undefined; diff --git a/client/src/components/Chat/Messages/Content/__tests__/ElicitationForm.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ElicitationForm.test.tsx new file mode 100644 index 00000000000..9c5445effa8 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/ElicitationForm.test.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { dataService } from 'librechat-data-provider'; +import ElicitationForm from '../ElicitationForm'; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => { + const translations: Record = { + com_ui_select: 'Select...', + com_ui_elicitation_submit: 'Submit', + com_ui_elicitation_decline: 'Decline', + com_ui_elicitation_cancel: 'Cancel', + com_ui_elicitation_continue: "I've authorized — continue", + com_ui_elicitation_open_url: 'Open authorization page', + com_ui_elicitation_completed: 'Form submitted', + com_ui_elicitation_declined: 'Declined', + com_ui_elicitation_cancelled: 'Cancelled', + com_ui_elicitation_authorized: 'Authorization confirmed', + }; + return translations[key] || key; + }, +})); + +jest.mock('librechat-data-provider', () => ({ + dataService: { + respondToElicitation: jest.fn().mockResolvedValue({ ok: true }), + }, +})); + +const baseSchema = { + type: 'object' as const, + properties: { + name: { + type: 'string' as const, + title: 'Your Name', + description: 'Enter your name', + }, + }, + required: ['name'], +}; + +const renderForm = (overrides = {}) => + render( + + + , + ); + +describe('ElicitationForm - form mode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the message and fields', () => { + renderForm(); + expect(screen.getByText('Please provide your info')).toBeInTheDocument(); + expect(screen.getByLabelText(/Your Name/)).toBeInTheDocument(); + expect(screen.getByText('Submit')).toBeInTheDocument(); + expect(screen.getByText('Decline')).toBeInTheDocument(); + }); + + it('shows completed status when action is accept', () => { + renderForm({ action: 'accept' }); + expect(screen.getByText('Form submitted')).toBeInTheDocument(); + expect(screen.queryByText('Submit')).not.toBeInTheDocument(); + }); + + it('shows declined status when action is decline', () => { + renderForm({ action: 'decline' }); + expect(screen.getByText('Declined')).toBeInTheDocument(); + }); + + it('validates required fields before submit', async () => { + renderForm(); + fireEvent.click(screen.getByText('Submit')); + expect(await screen.findByText('Your Name is required')).toBeInTheDocument(); + expect(dataService.respondToElicitation).not.toHaveBeenCalled(); + }); + + it('submits with accept action and field values', async () => { + renderForm(); + fireEvent.change(screen.getByLabelText(/Your Name/), { target: { value: 'Alice' } }); + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(dataService.respondToElicitation).toHaveBeenCalledWith('test-flow-1', { + action: 'accept', + content: { name: 'Alice' }, + }); + }); + expect(await screen.findByText('Form submitted')).toBeInTheDocument(); + }); + + it('submits with decline action without content', async () => { + renderForm(); + fireEvent.click(screen.getByText('Decline')); + + await waitFor(() => { + expect(dataService.respondToElicitation).toHaveBeenCalledWith('test-flow-1', { + action: 'decline', + content: undefined, + }); + }); + }); + + it('renders enum fields as select dropdowns', () => { + const schema = { + type: 'object' as const, + properties: { + fruit: { + type: 'string' as const, + title: 'Favorite Fruit', + enum: ['Apple', 'Banana', 'Mango'], + }, + }, + }; + renderForm({ requestedSchema: schema }); + expect(screen.getByLabelText('Favorite Fruit')).toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + }); + + it('renders boolean fields as checkboxes', () => { + const schema = { + type: 'object' as const, + properties: { + agree: { + type: 'boolean' as const, + title: 'I agree', + }, + }, + }; + renderForm({ requestedSchema: schema }); + const checkbox = screen.getByLabelText('I agree'); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toHaveAttribute('type', 'checkbox'); + }); + + it('renders number fields with min/max', () => { + const schema = { + type: 'object' as const, + properties: { + age: { + type: 'integer' as const, + title: 'Age', + minimum: 0, + maximum: 150, + }, + }, + }; + renderForm({ requestedSchema: schema }); + const input = screen.getByLabelText('Age'); + expect(input).toHaveAttribute('type', 'number'); + expect(input).toHaveAttribute('min', '0'); + expect(input).toHaveAttribute('max', '150'); + }); +}); + +const renderUrlForm = (overrides = {}) => + render( + + + , + ); + +describe('ElicitationForm - url mode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the message and an authorization link opening in a new tab', () => { + renderUrlForm(); + expect(screen.getByText('Please authorize access to your account')).toBeInTheDocument(); + + const link = screen.getByText('Open authorization page').closest('a'); + expect(link).toHaveAttribute('href', 'https://example.com/authorize?token=abc'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')); + }); + + it('renders Continue and Cancel controls', () => { + renderUrlForm(); + expect(screen.getByText("I've authorized — continue")).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('posts action "complete" when Continue is clicked and shows the authorized status', async () => { + renderUrlForm(); + fireEvent.click(screen.getByText("I've authorized — continue")); + + await waitFor(() => { + expect(dataService.respondToElicitation).toHaveBeenCalledWith('test-flow-url-1', { + action: 'complete', + content: undefined, + }); + }); + expect(await screen.findByText('Authorization confirmed')).toBeInTheDocument(); + }); + + it('posts action "cancel" when Cancel is clicked and shows the cancelled status', async () => { + renderUrlForm(); + fireEvent.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(dataService.respondToElicitation).toHaveBeenCalledWith('test-flow-url-1', { + action: 'cancel', + content: undefined, + }); + }); + expect(await screen.findByText('Cancelled')).toBeInTheDocument(); + }); + + it('shows the authorized status immediately when a persisted action is provided', () => { + renderUrlForm({ action: 'complete' }); + expect(screen.getByText('Authorization confirmed')).toBeInTheDocument(); + expect(screen.queryByText('Open authorization page')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index e9ef6e3227a..a06bdf07ec6 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -54,7 +54,8 @@ type TStepEvent = | { event: StepEvents.ON_SUMMARIZE_START; data: Agents.SummarizeStartEvent } | { event: StepEvents.ON_SUMMARIZE_DELTA; data: Agents.SummarizeDeltaEvent } | { event: StepEvents.ON_SUMMARIZE_COMPLETE; data: Agents.SummarizeCompleteEvent } - | { event: StepEvents.ON_SUBAGENT_UPDATE; data: SubagentUpdateEvent }; + | { event: StepEvents.ON_SUBAGENT_UPDATE; data: SubagentUpdateEvent } + | { event: StepEvents.ON_ELICITATION; data: Agents.ElicitationEvent }; type MessageDeltaUpdate = { type: ContentTypes.TEXT; text: string; tool_call_ids?: string[] }; @@ -965,6 +966,48 @@ export default function useStepHandler({ } } else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) { applySubagentUpdate(stepEvent.data); + } else if (stepEvent.event === StepEvents.ON_ELICITATION) { + const { id: eventStepId, runId: eventRunId, elicitation } = stepEvent.data; + const runStep = stepMap.current.get(eventStepId); + let responseMessageId = runStep?.runId ?? eventRunId ?? ''; + if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) { + responseMessageId = submission?.initialResponse?.messageId ?? ''; + parentMessageId = submission?.initialResponse?.parentMessageId ?? ''; + } + if (!responseMessageId) { + console.warn('No message id found in elicitation event'); + return; + } + + const response = messageMap.current.get(responseMessageId); + if (!response) { + console.warn('[on_elicitation] No response message found for', responseMessageId); + return; + } + + const contentPart: Agents.ElicitationContent = { + type: ContentTypes.ELICITATION, + elicitation, + }; + /** Elicitation content is a standalone card, not an incremental delta onto an + * existing content-typed slot (e.g. the originating tool call) — append it as + * its own part, deduping by flowId so a re-emitted event replaces in place. */ + const existingContent = (response.content ?? []) as TMessageContentParts[]; + const updatedContentArr = [ + ...existingContent.filter( + (part) => + part?.type !== ContentTypes.ELICITATION || + (part as Agents.ElicitationContent).elicitation?.flowId !== elicitation.flowId, + ), + contentPart, + ]; + const updatedResponse = { ...response, content: updatedContentArr }; + messageMap.current.set(responseMessageId, updatedResponse); + setMessages( + mergeResponseMessage(messages, updatedResponse, responseMessageId, { + ensureUserMessage: true, + }), + ); } else if (stepEvent.event === StepEvents.ON_SUMMARIZE_START) { announcePolite({ message: 'summarize_started', isStatus: true }); } else if (stepEvent.event === StepEvents.ON_SUMMARIZE_DELTA) { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 81df389135f..80b07ecf2e1 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1095,6 +1095,20 @@ "com_ui_edited_file": "Edited {{0}}", "com_ui_editing_file": "Editing {{0}}", "com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size", + "com_ui_elicitation_authorized": "Authorization confirmed", + "com_ui_elicitation_cancel": "Cancel", + "com_ui_elicitation_cancelled": "Cancelled", + "com_ui_elicitation_completed": "Form submitted", + "com_ui_elicitation_continue": "I've authorized — continue", + "com_ui_elicitation_decline": "Decline", + "com_ui_elicitation_declined": "Declined", + "com_ui_elicitation_error": "Couldn't send your response — try again.", + "com_ui_elicitation_form_empty": "The tool needs additional confirmation.", + "com_ui_elicitation_form_title": "Additional information needed", + "com_ui_elicitation_open_url": "Open authorization page", + "com_ui_elicitation_reopen": "Reopen page", + "com_ui_elicitation_submit": "Submit", + "com_ui_elicitation_title": "Authorization required", "com_ui_empty_category": "-", "com_ui_endpoint": "Endpoint", "com_ui_endpoint_menu": "LLM Endpoint Menu", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index ead04e0b877..1acfb56b20b 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -14,6 +14,7 @@ export * from './mcp/registry/MCPServersRegistry'; export * from './mcp/MCPManager'; export * from './mcp/connection'; export * from './mcp/oauth'; +export * from './mcp/elicitation'; export * from './mcp/auth'; export * from './mcp/zod'; export * from './mcp/errors'; diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index 0269937e5bb..f9f60dbebfa 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -1,9 +1,10 @@ import pick from 'lodash/pick'; -import { logger } from '@librechat/data-schemas'; +import { logger, getTenantId } from '@librechat/data-schemas'; import { Permissions, PermissionTypes } from 'librechat-data-provider'; import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { TokenMethods, IUser } from '@librechat/data-schemas'; +import type { Agents } from 'librechat-data-provider'; import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo'; import type { GraphTokenResolver } from '~/utils/graph'; import type { FlowStateManager } from '~/flow/manager'; @@ -18,6 +19,14 @@ import { requiresOAuthMachinery, requiresUserScopedConnection, } from './utils'; +import type { ElicitationFlowResult } from './elicitation'; +import { + asElicitationFlowManager, + extractUrlElicitation, + generateElicitationFlowId, + isElicitationSuccess, + toElicitResultAction, +} from './elicitation'; import { MCPServersInitializer } from './registry/MCPServersInitializer'; import { OboTokenResolutionError, resolveOboToken } from '~/mcp/oauth'; import { MCPServerInspector } from './registry/MCPServerInspector'; @@ -30,6 +39,10 @@ import { formatToolContent } from './parsers'; import { MCPConnection } from './connection'; import { processMCPEnv } from '~/utils/env'; +/** Extended tool-call timeout when an elicitation (form/url `elicitation/create`, + * or a -32042 URL-exception retry) may pause execution waiting on the user. */ +const ELICITATION_TIMEOUT_MS = 10 * 60 * 1000; + function createOboToolCallErrorMessage( logPrefix: string, toolName: string, @@ -353,6 +366,7 @@ Please follow these instructions when using tools from the respective MCP server flowManager, oauthStart, oauthEnd, + elicitationStart, customUserVars, graphTokenResolver, oboTokenResolver, @@ -373,6 +387,25 @@ Please follow these instructions when using tools from the respective MCP server flowManager: FlowStateManager; oauthStart?: t.OAuthStartHandler; oauthEnd?: () => Promise; + /** + * When provided: (1) declares support for MCP elicitation, extending the + * `tools/call` timeout to {@link ELICITATION_TIMEOUT_MS}; (2) registers a + * handler for server-initiated `elicitation/create` requests (form/url + * modes); and (3) catches the -32042 `UrlElicitationRequired` exception on + * the initial `tools/call` response and retries once after the user + * authorizes. Called once per elicitation with enough context to render an + * in-chat card; resolution arrives via `flowManager` (same pattern as + * `oauthStart`/OAuth). + */ + elicitationStart?: (params: { + flowId: string; + mode: 'form' | 'url'; + message: string; + serverName?: string; + toolName?: string; + requestedSchema?: Agents.ElicitationSchema; + url?: string; + }) => Promise; graphTokenResolver?: GraphTokenResolver; oboTokenResolver?: OboTokenResolver; oboTrustChecker?: OboTrustChecker; @@ -380,6 +413,7 @@ Please follow these instructions when using tools from the respective MCP server /** User-specific connection */ let connection: MCPConnection | undefined; let cleanupRequestOAuthHandler: (() => void) | undefined; + let cleanupElicitationHandler: (() => void) | undefined; let disconnectAfterCall = false; const userId = user?.id; const logPrefix = userId ? `[MCP][User: ${userId}][${serverName}]` : `[MCP][${serverName}]`; @@ -513,21 +547,119 @@ Please follow these instructions when using tools from the respective MCP server connection.setRequestHeaders(resolvedHeaders); - const result = await connection.client.request( - { - method: 'tools/call', - params: { - name: toolName, - arguments: toolArguments, - }, - }, - CallToolResultSchema, - { - timeout: connection.timeout, - resetTimeoutOnProgress: true, - ...options, + const elicitationFlowManager = asElicitationFlowManager(flowManager); + + if (elicitationStart && userId) { + cleanupElicitationHandler = connection.setElicitationHandler(async (params) => { + const isUrlMode = params.mode === 'url'; + const flowId = generateElicitationFlowId(userId, serverName, toolName, getTenantId()); + logger.debug( + `${logPrefix}[${toolName}] Elicitation requested (${isUrlMode ? 'url' : 'form'}), flowId: ${flowId}`, + ); + await elicitationStart({ + flowId, + mode: isUrlMode ? 'url' : 'form', + message: params.message, + serverName, + toolName, + requestedSchema: params.mode === 'url' ? undefined : params.requestedSchema, + url: params.mode === 'url' ? params.url : undefined, + }); + const flowResult = await elicitationFlowManager.createFlow( + flowId, + 'mcp_elicit', + {}, + options?.signal, + ); + logger.debug(`${logPrefix}[${toolName}] Elicitation resolved: ${flowResult.action}`); + return { + action: toElicitResultAction(flowResult.action), + content: flowResult.content, + }; + }); + } + + const requestParams = { + method: 'tools/call' as const, + params: { + name: toolName, + arguments: toolArguments, }, - ); + }; + const requestOptions = { + timeout: elicitationStart ? ELICITATION_TIMEOUT_MS : connection.timeout, + resetTimeoutOnProgress: true, + ...options, + }; + + let result: unknown; + try { + result = await connection.client.request( + requestParams, + CallToolResultSchema, + requestOptions, + ); + } catch (toolCallError) { + /** + * URL-exception mode (spec 2025-11-25): the server rejected `tools/call` + * with JSON-RPC code -32042 instead of issuing a normal `elicitation/create` + * request (possibly wrapped in an HTTP-level transport error by the + * gateway — see `extractUrlElicitation`). Surface the authorization link, + * wait for the user to complete (or cancel) it via `flowManager`, then + * retry the SAME `tools/call` once. + */ + if (!elicitationStart || !userId) { + throw toolCallError; + } + const first = extractUrlElicitation(toolCallError); + if (!first) { + throw toolCallError; + } + + const flowId = generateElicitationFlowId(userId, serverName, toolName, getTenantId()); + logger.info( + `${logPrefix}[${toolName}] URL elicitation required (-32042), flowId: ${flowId}`, + ); + await elicitationStart({ + flowId, + mode: 'url', + message: first.message, + serverName, + toolName, + url: first.url, + }); + + let flowResult: ElicitationFlowResult; + try { + flowResult = await elicitationFlowManager.createFlow( + flowId, + 'mcp_elicit', + {}, + options?.signal, + ); + } catch (flowError) { + const reason = flowError instanceof Error ? flowError.message : String(flowError); + throw new McpError( + ErrorCode.InvalidRequest, + `${first.message} Open ${first.url} to authorize, then retry. (${reason})`, + ); + } + + if (!isElicitationSuccess(flowResult.action)) { + throw new McpError( + ErrorCode.InvalidRequest, + `${first.message} Authorization was cancelled. Open ${first.url} to authorize, then retry.`, + ); + } + + logger.debug(`${logPrefix}[${toolName}] URL elicitation authorized, retrying tools/call`); + result = await connection.client.request( + requestParams, + CallToolResultSchema, + requestOptions, + ); + } + if (userId) { this.updateUserLastActivity(userId); } @@ -540,6 +672,9 @@ Please follow these instructions when using tools from the respective MCP server throw error; } finally { cleanupRequestOAuthHandler?.(); + // Unregister the per-call elicitation handler so a cached connection can't + // fire a stale closure (old toolName/signal/flow) on a later request. + cleanupElicitationHandler?.(); // Ephemeral connections are never stored in userConnections, so disconnecting // is the only cleanup needed; removing the map entry here could orphan a // still-connected cached connection from before a config change. diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 865de69eb4e..a08f2820e1d 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -21,6 +21,7 @@ jest.mock('@librechat/data-schemas', () => ({ error: jest.fn(), debug: jest.fn(), }, + getTenantId: jest.fn(), })); jest.mock('~/utils/graph', () => ({ @@ -2007,4 +2008,399 @@ describe('MCPManager', () => { ).rejects.toThrow('requires a flowManager'); }); }); + + describe('callTool - URL Elicitation (-32042)', () => { + const mockUser: Partial = { id: 'user-url-elicit' }; + + function makeUrlElicitationError(overrides: Partial<{ message: string; url: string }> = {}) { + const error = new Error('URL elicitation required') as Error & { + code: number; + data: { + elicitations: Array<{ + mode: string; + message: string; + url: string; + elicitationId: string; + }>; + }; + }; + error.code = -32042; + error.data = { + elicitations: [ + { + mode: 'url', + message: overrides.message ?? 'Please authorize access to your account', + url: overrides.url ?? 'https://auth.example.com/authorize?token=abc', + elicitationId: 'elicit-1', + }, + ], + }; + return error; + } + + let mockFlowManager: { + createFlow: jest.Mock; + }; + let mockConnection: MCPConnection; + + beforeEach(() => { + jest.clearAllMocks(); + mockFlowManager = { createFlow: jest.fn() }; + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + type: 'stdio', + command: 'node', + args: ['server.js'], + }); + }); + + it('retries tools/call once after the user completes the -32042 authorization flow', async () => { + const urlError = makeUrlElicitationError(); + mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + setElicitationHandler: jest.fn(), + timeout: 30000, + client: { + request: jest + .fn() + .mockRejectedValueOnce(urlError) + .mockResolvedValueOnce({ + content: [{ type: 'text', text: 'ok after auth' }], + isError: false, + }), + }, + } as unknown as MCPConnection; + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + mockFlowManager.createFlow.mockResolvedValue({ action: 'complete' }); + + const elicitationStart = jest.fn().mockResolvedValue(undefined); + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + const result = await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + elicitationStart, + }); + + expect(elicitationStart).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'url', + message: 'Please authorize access to your account', + url: 'https://auth.example.com/authorize?token=abc', + }), + ); + expect(mockFlowManager.createFlow).toHaveBeenCalledWith( + expect.any(String), + 'mcp_elicit', + {}, + undefined, + ); + expect(mockConnection.client.request).toHaveBeenCalledTimes(2); + expect(result).toBeDefined(); + }); + + it('fails the tool call with the elicitation message and URL when the user cancels', async () => { + const urlError = makeUrlElicitationError(); + mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + setElicitationHandler: jest.fn(), + timeout: 30000, + client: { request: jest.fn().mockRejectedValueOnce(urlError) }, + } as unknown as MCPConnection; + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + mockFlowManager.createFlow.mockResolvedValue({ action: 'cancel' }); + + const elicitationStart = jest.fn().mockResolvedValue(undefined); + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await expect( + manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + elicitationStart, + }), + ).rejects.toThrow( + /Please authorize access to your account[\s\S]*https:\/\/auth\.example\.com/, + ); + expect(mockConnection.client.request).toHaveBeenCalledTimes(1); + }); + + it('fails the tool call with the elicitation message and URL when the flow times out', async () => { + const urlError = makeUrlElicitationError(); + mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + setElicitationHandler: jest.fn(), + timeout: 30000, + client: { request: jest.fn().mockRejectedValueOnce(urlError) }, + } as unknown as MCPConnection; + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + mockFlowManager.createFlow.mockRejectedValue(new Error('mcp_elicit flow timed out')); + + const elicitationStart = jest.fn().mockResolvedValue(undefined); + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await expect( + manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + elicitationStart, + }), + ).rejects.toThrow( + /Please authorize access to your account[\s\S]*https:\/\/auth\.example\.com[\s\S]*timed out/, + ); + }); + + it('does not intercept the -32042 error when elicitationStart is not provided', async () => { + const urlError = makeUrlElicitationError(); + mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + setElicitationHandler: jest.fn(), + timeout: 30000, + client: { request: jest.fn().mockRejectedValueOnce(urlError) }, + } as unknown as MCPConnection; + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await expect( + manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + }), + ).rejects.toBe(urlError); + expect(mockConnection.setElicitationHandler).not.toHaveBeenCalled(); + }); + }); + + describe('callTool - Elicitation (form/url elicitation/create)', () => { + const mockUser: Partial = { id: 'user-elicit' }; + + const mockFlowManager = { + getState: jest.fn(), + setState: jest.fn(), + clearState: jest.fn(), + createFlow: jest.fn().mockResolvedValue({ + action: 'accept', + content: { name: 'Alice' }, + }), + }; + + const mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + setElicitationHandler: jest.fn(), + timeout: 30000, + client: { + request: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Elicitation result' }], + isError: false, + }), + }, + } as unknown as MCPConnection; + + beforeEach(() => { + jest.clearAllMocks(); + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + type: 'stdio', + command: 'node', + args: ['server.js'], + }); + }); + + it('registers an elicitation handler on the connection when elicitationStart is provided', async () => { + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + const elicitationStart = jest.fn().mockResolvedValue(undefined); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + elicitationStart, + }); + + expect(mockConnection.setElicitationHandler).toHaveBeenCalledTimes(1); + expect(mockConnection.setElicitationHandler).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('clears the elicitation handler once the tool call settles', async () => { + const cleanup = jest.fn(); + const connection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + setElicitationHandler: jest.fn().mockReturnValue(cleanup), + timeout: 30000, + client: { + request: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Elicitation result' }], + isError: false, + }), + }, + } as unknown as MCPConnection; + mockAppConnections({ get: jest.fn().mockResolvedValue(connection) }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + const elicitationStart = jest.fn().mockResolvedValue(undefined); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + elicitationStart, + }); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('forwards form-mode elicitation/create requests and resolves via the flow manager', async () => { + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + mockFlowManager.createFlow.mockResolvedValueOnce({ + action: 'accept', + content: { name: 'Alice' }, + }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + const elicitationStart = jest.fn().mockResolvedValue(undefined); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + elicitationStart, + }); + + const registeredHandler = (mockConnection.setElicitationHandler as jest.Mock).mock + .calls[0][0]; + const requestedSchema = { type: 'object', properties: { name: { type: 'string' } } }; + const result = await registeredHandler({ message: 'Please fill this in', requestedSchema }); + + expect(elicitationStart).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'form', message: 'Please fill this in', requestedSchema }), + ); + expect(result).toEqual({ action: 'accept', content: { name: 'Alice' } }); + }); + + it('forwards url-mode elicitation/create requests and maps a "complete" flow result to "accept"', async () => { + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + mockFlowManager.createFlow.mockResolvedValueOnce({ action: 'complete' }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + const elicitationStart = jest.fn().mockResolvedValue(undefined); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + elicitationStart, + }); + + const registeredHandler = (mockConnection.setElicitationHandler as jest.Mock).mock + .calls[0][0]; + const result = await registeredHandler({ + mode: 'url', + message: 'Please authorize', + elicitationId: 'elicit-2', + url: 'https://auth.example.com', + }); + + expect(elicitationStart).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'url', + message: 'Please authorize', + url: 'https://auth.example.com', + requestedSchema: undefined, + }), + ); + /** ElicitResultSchema only accepts accept/decline/cancel — 'complete' isn't a + * valid protocol response, so it must be mapped onto 'accept'. */ + expect(result).toEqual({ action: 'accept', content: undefined }); + }); + + it('uses extended timeout when elicitationStart is provided', async () => { + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + const elicitationStart = jest.fn().mockResolvedValue(undefined); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + elicitationStart, + }); + + expect(mockConnection.client.request).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.objectContaining({ timeout: 10 * 60 * 1000 }), + ); + }); + + it('uses default timeout when elicitationStart is not provided', async () => { + mockAppConnections({ get: jest.fn().mockResolvedValue(mockConnection) }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + }); + + expect(mockConnection.setElicitationHandler).not.toHaveBeenCalled(); + expect(mockConnection.client.request).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.objectContaining({ timeout: mockConnection.timeout }), + ); + }); + }); }); diff --git a/packages/api/src/mcp/__tests__/elicitation.test.ts b/packages/api/src/mcp/__tests__/elicitation.test.ts new file mode 100644 index 00000000000..c6c8de4d2b1 --- /dev/null +++ b/packages/api/src/mcp/__tests__/elicitation.test.ts @@ -0,0 +1,173 @@ +import { + asElicitationFlowManager, + extractUrlElicitation, + generateElicitationFlowId, + parseElicitationFlowId, +} from '~/mcp/elicitation'; + +describe('elicitation flow IDs', () => { + it('round-trips the userId, serverName, and toolName it was built from', () => { + const flowId = generateElicitationFlowId('user-1', 'jira_server', 'create_issue'); + + expect(parseElicitationFlowId(flowId)).toEqual({ + userId: 'user-1', + serverName: 'jira_server', + toolName: 'create_issue', + nonce: expect.any(String), + tenantId: undefined, + }); + }); + + it('preserves a colon inside a segment instead of skewing the parsed fields', () => { + const flowId = generateElicitationFlowId('user-1', 'ns:jira', 'do:thing'); + const parsed = parseElicitationFlowId(flowId); + + expect(parsed?.serverName).toBe('ns:jira'); + expect(parsed?.toolName).toBe('do:thing'); + expect(parsed?.userId).toBe('user-1'); + }); + + it('round-trips the tenantId for tenant-scoped flows', () => { + const flowId = generateElicitationFlowId('user-1', 'jira_server', 'create_issue', 'acme:eu'); + const parsed = parseElicitationFlowId(flowId); + + expect(parsed?.tenantId).toBe('acme:eu'); + expect(parsed?.serverName).toBe('jira_server'); + }); + + it('generates a distinct ID per invocation so concurrent calls never collide', () => { + const a = generateElicitationFlowId('user-1', 'jira_server', 'create_issue'); + const b = generateElicitationFlowId('user-1', 'jira_server', 'create_issue'); + + expect(a).not.toBe(b); + }); + + it('returns null for IDs with too few segments', () => { + expect(parseElicitationFlowId('user-1:jira_server')).toBeNull(); + expect(parseElicitationFlowId('tenant:acme:user-1')).toBeNull(); + }); +}); + +describe('asElicitationFlowManager', () => { + it('re-views the same manager instance without copying it', () => { + const manager = { createFlow: jest.fn(), completeFlow: jest.fn() }; + + expect(asElicitationFlowManager(manager)).toBe(manager); + }); +}); + +describe('MCPConnection.setElicitationHandler disposal', () => { + // Exercises the real method against a stubbed SDK client: registration is + // last-writer-wins (the protocol offers no call correlation), but disposal is + // token-guarded — an earlier call settling must never tear down a later + // call's live handler. + const { MCPConnection } = jest.requireActual('../connection'); + + const makeFakeConnection = () => { + const client = { setRequestHandler: jest.fn(), removeRequestHandler: jest.fn() }; + return { client, connection: { client } as unknown as InstanceType }; + }; + const handler = () => Promise.resolve({ action: 'accept' as const }); + + it('removes the handler when the registering call settles last', () => { + const { client, connection } = makeFakeConnection(); + const dispose = MCPConnection.prototype.setElicitationHandler.call(connection, handler); + + dispose(); + + expect(client.removeRequestHandler).toHaveBeenCalledTimes(1); + expect(client.removeRequestHandler).toHaveBeenCalledWith('elicitation/create'); + }); + + it("never removes a newer call's handler, and disposal is idempotent", () => { + const { client, connection } = makeFakeConnection(); + const disposeFirst = MCPConnection.prototype.setElicitationHandler.call(connection, handler); + const disposeSecond = MCPConnection.prototype.setElicitationHandler.call(connection, handler); + + disposeFirst(); + expect(client.removeRequestHandler).not.toHaveBeenCalled(); + + disposeSecond(); + disposeSecond(); + expect(client.removeRequestHandler).toHaveBeenCalledTimes(1); + }); +}); + +describe('extractUrlElicitation', () => { + const elicitation = { + mode: 'url', + message: 'Please authorize access to github', + url: 'https://bedrock-agentcore.eu-west-1.amazonaws.com/identities/oauth2/authorize?request_uri=abc', + elicitationId: '8cd9f2ba-103d-44c9-8471-6dd02df67c1b', + }; + + it('extracts from a protocol-level McpError shape (code -32042 + data)', () => { + const error = { code: -32042, data: { elicitations: [elicitation] } }; + expect(extractUrlElicitation(error)).toEqual(elicitation); + }); + + it('extracts from a gateway HTTP-wrapped transport error (the AgentCore wire shape)', () => { + // Exact shape observed live: gateway returns JSON-RPC errors with a non-2xx + // HTTP status, so the SDK throws a StreamableHTTPError whose message embeds + // the raw body and whose `code` is the HTTP status, not -32042. + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 9, + error: { + code: -32042, + message: 'This request requires authorization.', + data: { elicitations: [elicitation] }, + }, + }); + const error = Object.assign( + new Error(`Streamable HTTP error: Error POSTing to endpoint: ${body}`), + { + code: 401, + }, + ); + expect(extractUrlElicitation(error)).toEqual(elicitation); + }); + + it('extracts a whitespaced HTTP-wrapped body (pretty-printed / key-reordered gateway JSON)', () => { + // A gateway that pretty-prints yields `"code": -32042` (note the space) and + // may order keys differently; a literal `"code":-32042` substring match would + // miss it, so extraction must tolerate JSON formatting variance. + const body = JSON.stringify( + { + jsonrpc: '2.0', + id: 9, + error: { + message: 'This request requires authorization.', + data: { elicitations: [elicitation] }, + code: -32042, + }, + }, + null, + 2, + ); + const error = Object.assign( + new Error(`Streamable HTTP error: Error POSTing to endpoint: ${body}`), + { code: 401 }, + ); + expect(extractUrlElicitation(error)).toEqual(elicitation); + }); + + it('returns null for non-elicitation errors in both shapes', () => { + expect(extractUrlElicitation(new Error('boom'))).toBeNull(); + expect(extractUrlElicitation({ code: -32600, data: {} })).toBeNull(); + expect( + extractUrlElicitation( + new Error( + 'Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"Session not initialized"}}', + ), + ), + ).toBeNull(); + expect(extractUrlElicitation(null)).toBeNull(); + }); + + it('returns null for a -32042 mention with an unparseable body', () => { + expect( + extractUrlElicitation(new Error('Error POSTing to endpoint: {"code":-32042, truncated')), + ).toBeNull(); + }); +}); diff --git a/packages/api/src/mcp/__tests__/helpers/mockElicitationServer.ts b/packages/api/src/mcp/__tests__/helpers/mockElicitationServer.ts new file mode 100644 index 00000000000..2c67b4711a8 --- /dev/null +++ b/packages/api/src/mcp/__tests__/helpers/mockElicitationServer.ts @@ -0,0 +1,401 @@ +import * as http from 'http'; +import type { AddressInfo, Socket } from 'net'; +import { randomUUID } from 'crypto'; + +/** Tracks open sockets so `close()` can force-destroy keep-alive connections; + * a plain `server.close()` otherwise hangs until idle clients disconnect. */ +function trackSockets(httpServer: http.Server): () => Promise { + const sockets = new Set(); + httpServer.on('connection', (socket: Socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + return () => + new Promise((resolve) => { + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + httpServer.close(() => resolve()); + }); +} + +/** + * How the mock delivers the -32042 `UrlElicitationRequired` error, mirroring the + * two wire shapes {@link extractUrlElicitation} must survive: + * + * - `http-401`: the shape AWS Bedrock AgentCore Gateway returns live — a JSON-RPC + * error body carried on a **non-2xx** HTTP status, so the SDK's streamable-HTTP + * transport never parses it as a protocol message and instead throws a + * `StreamableHTTPError` whose `.message` embeds the raw body. + * - `jsonrpc-200`: the spec-pure shape — a JSON-RPC error body on HTTP **200**, + * which the SDK parses and surfaces as an `McpError` with `code === -32042`. + */ +export type ElicitationWireShape = 'http-401' | 'jsonrpc-200'; + +/** + * JSON serialization of the error body. Gateways/serializers vary here and the + * exact byte layout matters: an HTTP-wrapped -32042 is matched by scanning the + * SDK error's `.message` string, so `'pretty'` (which yields `"code": -32042` + * with a space) is a realistic shape that a naive substring match would miss. + */ +export type ElicitationBodyFormat = 'compact' | 'pretty'; + +export interface MockElicitationServerOptions { + /** Fixed port to bind; when omitted an ephemeral port is chosen. */ + port?: number; + /** Initial value of the in-memory authorization gate. Default `false`. */ + authorized?: boolean; + /** How the -32042 is delivered. Default `'http-401'` (the AgentCore shape). */ + wireShape?: ElicitationWireShape; + /** JSON layout of the error body. Default `'compact'`. */ + bodyFormat?: ElicitationBodyFormat; + /** + * When true, the NEXT `tools/call` (any tool) is answered with a -32600 + * "Session not initialized" JSON-RPC error, then the flag auto-clears — models + * the stale-session state seen in production after a reconnection is abandoned. + * A subsequent call (or a client that re-initializes and retries) succeeds. + */ + sessionErrorOnce?: boolean; +} + +/** The `initialize` request params exactly as they arrived on the wire — lets + * tests assert the protocolVersion and capabilities the real client stack sends + * (e.g. whether URL-mode elicitation capability actually reaches the server). */ +export interface CapturedInitializeRequest { + protocolVersion?: string; + capabilities?: Record; + clientInfo?: Record; +} + +export interface MockElicitationServerState { + /** Flipped to `true` by `GET /consent`; gates `get_secret`. */ + authorized: boolean; + wireShape: ElicitationWireShape; + bodyFormat: ElicitationBodyFormat; + /** `tools/call` invocations received, keyed by tool name — asserts retry counts. */ + callCounts: Record; + /** `elicitationId` handed out on the most recent -32042. */ + lastElicitationId?: string; + /** Raw `initialize` params from the most recent handshake. */ + initializeRequest?: CapturedInitializeRequest; + /** When true, the next `tools/call` returns -32600 then clears (see options). */ + sessionErrorOnce: boolean; +} + +export interface MockElicitationServer { + /** The MCP endpoint LibreChat connects to (`http://127.0.0.1:/mcp`). */ + readonly url: string; + /** The authorization link embedded in elicitations (`.../consent`). */ + readonly consentUrl: string; + /** Non-mutating-for-the-human helper link (`.../reset`) that sets + * `authorized=false` so a fresh UI round starts gated again — no process + * restart needed. Safe to hit in smoke checks (unlike `/consent`). */ + readonly resetUrl: string; + readonly port: number; + readonly state: MockElicitationServerState; + /** Resets the authorization gate, wire shape, body format, and call counters. */ + reset(options?: { + authorized?: boolean; + wireShape?: ElicitationWireShape; + bodyFormat?: ElicitationBodyFormat; + }): void; + close(): Promise; +} + +interface JsonRpcMessage { + jsonrpc: '2.0'; + id?: string | number; + method?: string; + params?: Record; +} + +const CONSENT_PAGE = ` + + Authorization complete + +

✅ Authorization complete

+

You can close this tab and return to the chat, then retry the tool.

+ +`; + +const RESET_PAGE = ` + + Reset + +

🔄 Reset

+

The next get_secret call will require authorization again.

+ +`; + +async function readRequestBody(req: http.IncomingMessage): Promise { + const chunks: Uint8Array[] = []; + for await (const chunk of req) { + chunks.push(chunk as Uint8Array); + } + return Buffer.concat(chunks).toString(); +} + +function sendJson(res: http.ServerResponse, status: number, payload: unknown): void { + const body = JSON.stringify(payload); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(body); +} + +const WHOAMI_TOOL = { + name: 'whoami', + description: 'Returns a fixed identity string; never requires authorization.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, +}; + +const GET_SECRET_TOOL = { + name: 'get_secret', + description: 'Returns a secret payload; gated behind a URL elicitation until authorized.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, +}; + +/** + * Starts a standalone MCP Streamable-HTTP server that reproduces the AgentCore + * Gateway's URL-elicitation behavior with exact control over HTTP status codes + * (which the real SDK server does not expose). Backed by raw `node:http`. + * + * Endpoints: + * - `POST /mcp` — MCP protocol: `initialize`, `notifications/initialized`, + * `tools/list` (`whoami`, `get_secret`), and `tools/call`. `get_secret` + * emits -32042 in the configured wire shape until `authorized` flips true. + * - `GET /consent` — flips `authorized` true and returns a small HTML page, so a + * human clicking the elicitation URL completes the flow with zero effort. + * - `GET /mcp` — 405 (no standalone SSE stream; an expected, error-free case for + * the SDK's streamable-HTTP transport). + */ +export async function startMockElicitationServer( + options: MockElicitationServerOptions = {}, +): Promise { + const requestedPort = options.port ?? 0; + const state: MockElicitationServerState = { + authorized: options.authorized ?? false, + wireShape: options.wireShape ?? 'http-401', + bodyFormat: options.bodyFormat ?? 'compact', + sessionErrorOnce: options.sessionErrorOnce ?? false, + callCounts: {}, + }; + + // Finalized once the OS assigns a port (handles `port: 0`); the request-handler + // and error-builder closures read these bindings at call time, after listen. + let origin = `http://127.0.0.1:${requestedPort}`; + let consentUrl = `${origin}/consent`; + + const buildElicitationError = (id: string | number | undefined) => { + const elicitationId = randomUUID(); + state.lastElicitationId = elicitationId; + return { + jsonrpc: '2.0' as const, + id: id ?? null, + error: { + code: -32042, + message: 'This request requires authorization.', + data: { + elicitations: [ + { + mode: 'url', + url: consentUrl, + message: 'Please authorize access to the mock secret', + elicitationId, + }, + ], + }, + }, + }; + }; + + const handleRpc = (msg: JsonRpcMessage, res: http.ServerResponse): void => { + const { id, method, params } = msg; + + switch (method) { + case 'initialize': + state.initializeRequest = { + protocolVersion: params?.protocolVersion as string | undefined, + capabilities: params?.capabilities as Record | undefined, + clientInfo: params?.clientInfo as Record | undefined, + }; + sendJson(res, 200, { + jsonrpc: '2.0', + id: id ?? null, + // Echo the client's requested version so negotiation always succeeds + // (whatever the client sent is, by definition, a version it supports). + result: { + protocolVersion: (params?.protocolVersion as string) ?? '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'mock-elicitation-server', version: '1.0.0' }, + }, + }); + return; + + case 'notifications/initialized': + // A notification has no id and expects no body — 202 Accepted. + res.writeHead(202).end(); + return; + + case 'tools/list': + sendJson(res, 200, { + jsonrpc: '2.0', + id: id ?? null, + result: { tools: [WHOAMI_TOOL, GET_SECRET_TOOL] }, + }); + return; + + case 'tools/call': { + const toolName = (params?.name as string) ?? ''; + state.callCounts[toolName] = (state.callCounts[toolName] ?? 0) + 1; + + if (state.sessionErrorOnce) { + state.sessionErrorOnce = false; + sendJson(res, 200, { + jsonrpc: '2.0', + id: id ?? null, + error: { + code: -32600, + message: 'Session not initialized. Send notifications/initialized first.', + }, + }); + return; + } + + if (toolName === 'whoami') { + sendJson(res, 200, { + jsonrpc: '2.0', + id: id ?? null, + result: { content: [{ type: 'text', text: 'mock-user' }] }, + }); + return; + } + + if (toolName === 'get_secret') { + if (state.authorized) { + sendJson(res, 200, { + jsonrpc: '2.0', + id: id ?? null, + result: { content: [{ type: 'text', text: 's3cr3t-payload' }] }, + }); + return; + } + const errorBody = buildElicitationError(id); + const serialized = + state.bodyFormat === 'pretty' + ? JSON.stringify(errorBody, null, 2) + : JSON.stringify(errorBody); + res.writeHead(state.wireShape === 'http-401' ? 401 : 200, { + 'content-type': 'application/json', + }); + res.end(serialized); + return; + } + + sendJson(res, 200, { + jsonrpc: '2.0', + id: id ?? null, + error: { code: -32602, message: `Unknown tool: ${toolName}` }, + }); + return; + } + + default: + if (id === undefined) { + // Unknown notification — nothing to answer. + res.writeHead(202).end(); + return; + } + sendJson(res, 200, { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }); + } + }; + + const server = http.createServer((req, res) => { + void (async () => { + try { + const url = new URL(req.url ?? '/', origin); + + if (req.method === 'GET' && url.pathname === '/consent') { + state.authorized = true; + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(CONSENT_PAGE); + return; + } + + if (req.method === 'GET' && url.pathname === '/reset') { + // De-authorize so repeated UI test rounds start gated again without a + // process restart. Unlike /consent this is safe to hit in smoke checks. + state.authorized = false; + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(RESET_PAGE); + return; + } + + if (url.pathname === '/mcp') { + if (req.method === 'POST') { + const raw = await readRequestBody(req); + const parsed = JSON.parse(raw) as JsonRpcMessage | JsonRpcMessage[]; + if (Array.isArray(parsed)) { + // The SDK sends one message per request; a batch is unexpected here. + sendJson(res, 400, { + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Batched requests are not supported by the mock.' }, + }); + return; + } + handleRpc(parsed, res); + return; + } + if (req.method === 'DELETE') { + // Session termination — acknowledge so client.close() is clean. + res.writeHead(200).end(); + return; + } + if (req.method === 'GET') { + // No standalone SSE stream; the SDK treats 405 as expected/no-error. + res.writeHead(405).end(); + return; + } + } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found' })); + } catch (error) { + sendJson(res, 500, { + jsonrpc: '2.0', + id: null, + error: { code: -32603, message: error instanceof Error ? error.message : String(error) }, + }); + } + })(); + }); + + const destroySockets = trackSockets(server); + await new Promise((resolve) => server.listen(requestedPort, '127.0.0.1', resolve)); + const boundPort = (server.address() as AddressInfo).port; + origin = `http://127.0.0.1:${boundPort}`; + consentUrl = `${origin}/consent`; + + return { + url: `${origin}/mcp`, + consentUrl, + resetUrl: `${origin}/reset`, + port: boundPort, + state, + reset(resetOptions) { + state.authorized = resetOptions?.authorized ?? false; + state.wireShape = resetOptions?.wireShape ?? state.wireShape; + state.bodyFormat = resetOptions?.bodyFormat ?? state.bodyFormat; + state.sessionErrorOnce = false; + state.callCounts = {}; + state.lastElicitationId = undefined; + // Intentionally NOT clearing initializeRequest — a fresh connect overwrites + // it, and tests may assert on it after resetting other state. + }, + close: () => destroySockets(), + }; +} diff --git a/packages/api/src/mcp/__tests__/helpers/package.json b/packages/api/src/mcp/__tests__/helpers/package.json new file mode 100644 index 00000000000..3dbc1ca591c --- /dev/null +++ b/packages/api/src/mcp/__tests__/helpers/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/api/src/mcp/__tests__/helpers/run-mock-elicitation-server.mjs b/packages/api/src/mcp/__tests__/helpers/run-mock-elicitation-server.mjs new file mode 100644 index 00000000000..fd8af661f07 --- /dev/null +++ b/packages/api/src/mcp/__tests__/helpers/run-mock-elicitation-server.mjs @@ -0,0 +1,51 @@ +/** + * Runnable wrapper around the mock elicitation server for zero-effort manual + * testing: start it, point LibreChat at `http://localhost:3111/mcp`, ask an + * agent to call `get_secret`, click the authorization link the chat renders, + * then retry — the second call returns `s3cr3t-payload`. + * + * Run from `packages/api` (Node >= 22 strips the imported .ts types; the + * `package.json` in this directory marks it as an ES module so the import + * resolves): + * + * node ./src/mcp/__tests__/helpers/run-mock-elicitation-server.mjs + * + * Env: + * MOCK_ELICITATION_PORT (default 3111) + * MOCK_ELICITATION_SHAPE 'http-401' (default) | 'jsonrpc-200' + * MOCK_ELICITATION_FORMAT 'compact' (default) | 'pretty' + */ +import { startMockElicitationServer } from './mockElicitationServer.ts'; + +const port = Number(process.env.MOCK_ELICITATION_PORT ?? 3111); +const wireShape = process.env.MOCK_ELICITATION_SHAPE ?? 'http-401'; +const bodyFormat = process.env.MOCK_ELICITATION_FORMAT ?? 'compact'; + +const server = await startMockElicitationServer({ port, wireShape, bodyFormat }); + +console.log( + [ + '', + `Mock AgentCore elicitation server listening on ${server.url}`, + ` wire shape : ${wireShape}`, + ` body format: ${bodyFormat}`, + ` consent URL: ${server.consentUrl}`, + ` reset URL : ${server.resetUrl} (GET flips authorized=false for a fresh round)`, + '', + 'librechat.yaml:', + ' mcpServers:', + ' mock-elicitation:', + ' type: streamable-http', + ` url: http://localhost:${server.port}/mcp`, + '', + 'Ask the agent to call "get_secret", click the authorization link, then retry.', + 'Press Ctrl+C to stop.', + '', + ].join('\n'), +); + +const shutdown = () => { + server.close().then(() => process.exit(0)); +}; +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/packages/api/src/mcp/__tests__/urlElicitation.integration.test.ts b/packages/api/src/mcp/__tests__/urlElicitation.integration.test.ts new file mode 100644 index 00000000000..644496d22b5 --- /dev/null +++ b/packages/api/src/mcp/__tests__/urlElicitation.integration.test.ts @@ -0,0 +1,330 @@ +import { randomUUID } from 'crypto'; +import type { IUser } from '@librechat/data-schemas'; +import type { FlowStateManager } from '~/flow/manager'; +import { MCPManager } from '../MCPManager'; +import { MCPConnection } from '../connection'; +import { MCPServersRegistry } from '../registry/MCPServersRegistry'; +import { + startMockElicitationServer, + type ElicitationWireShape, + type MockElicitationServer, +} from './helpers/mockElicitationServer'; + +/** + * Exercises the REAL client stack end to end against a mock AgentCore-style + * gateway: `MCPManager.callTool` → real SDK `Client` → real + * `StreamableHTTPClientTransport` (with LibreChat's guarded fetch) → mock HTTP + * server. The only stubs are `getConnection` (so we can point callTool at our + * connection) and the flow manager (so "user consent" resolves synchronously). + * + * This is the harness the unit tests in `elicitation.test.ts` can't be: it + * proves the -32042 exception actually round-trips through the SDK in both wire + * shapes and that callTool emits the elicitation, retries exactly once, and + * returns the post-authorization result. + */ + +const USER = { id: 'user-1' } as unknown as IUser; + +const WIRE_SHAPES: ElicitationWireShape[] = ['http-401', 'jsonrpc-200']; + +function streamableHttpConfig(url: string) { + return { type: 'streamable-http' as const, url }; +} + +/** A flow manager whose `createFlow` simulates the user completing consent by + * hitting the mock's `/consent` link before resolving with `complete`. */ +function consentingFlowManager(server: MockElicitationServer) { + const createFlow = jest.fn(async () => { + await fetch(server.consentUrl); + return { action: 'complete' as const }; + }); + return { manager: { createFlow } as unknown as FlowStateManager, createFlow }; +} + +/** A flow manager whose `createFlow` rejects — models a timeout / user cancel. */ +function rejectingFlowManager(error: Error) { + const createFlow = jest.fn(async () => { + throw error; + }); + return { manager: { createFlow } as unknown as FlowStateManager, createFlow }; +} + +/** A flow manager that must never be consulted (asserts "no retry" paths). */ +function unusedFlowManager() { + const createFlow = jest.fn(async () => { + throw new Error('createFlow should not have been called'); + }); + return { manager: { createFlow } as unknown as FlowStateManager, createFlow }; +} + +async function connectToMock(server: MockElicitationServer): Promise<{ + manager: MCPManager; + connection: MCPConnection; + serverName: string; +}> { + // Unique server name per connection so the static circuit breaker never + // couples one test's reconnects to another's. + const serverName = `mock-${randomUUID()}`; + const connection = new MCPConnection({ + serverName, + serverConfig: streamableHttpConfig(server.url), + useSSRFProtection: false, + }); + await connection.connect(); + + const manager = new MCPManager(); + jest.spyOn(manager, 'getConnection').mockResolvedValue(connection); + return { manager, connection, serverName }; +} + +function callGetSecret( + manager: MCPManager, + server: MockElicitationServer, + serverName: string, + opts: { + flowManager: FlowStateManager; + elicitationStart?: jest.Mock; + }, +) { + return manager.callTool({ + user: USER, + serverName, + serverConfig: streamableHttpConfig(server.url), + toolName: 'get_secret', + provider: 'openai', + flowManager: opts.flowManager as never, + elicitationStart: opts.elicitationStart as never, + }); +} + +describe('URL elicitation (-32042) integration', () => { + let server: MockElicitationServer; + let connection: MCPConnection | undefined; + + beforeAll(async () => { + server = await startMockElicitationServer(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + // callTool calls MCPServersRegistry.getInstance() unconditionally; it is + // only *used* for the paths we bypass (providedConfig + no OAuth), so a bare + // stub keeps the singleton-init requirement out of the test. + jest + .spyOn(MCPServersRegistry, 'getInstance') + .mockReturnValue({} as unknown as MCPServersRegistry); + }); + + afterEach(async () => { + if (connection) { + await connection.disconnect(); + connection = undefined; + } + }); + + it('declares protocolVersion 2025-11-25 and URL-mode elicitation on the initialize wire', async () => { + server.reset({ authorized: false, wireShape: 'http-401' }); + const built = await connectToMock(server); + connection = built.connection; + + const init = server.state.initializeRequest; + // Ground truth of what the real client stack put on the wire — reported verbatim. + console.log('CAPTURED_INITIALIZE', JSON.stringify(init, null, 2)); + + expect(init).toBeDefined(); + // URL-mode elicitation passthrough is gated on version 2025-11-25 + capability. + expect(init?.protocolVersion).toBe('2025-11-25'); + // The capability object must survive to the wire (not stripped by the SDK) and + // declare URL mode — `elicitation.url` present is how the SDK signals url support. + const elicitation = init?.capabilities?.elicitation as + | { form?: unknown; url?: unknown } + | undefined; + expect(elicitation).toBeDefined(); + expect(elicitation?.url).toBeDefined(); + expect(elicitation?.form).toBeDefined(); + }); + + it('sanity: a non-gated tool (whoami) succeeds without any elicitation', async () => { + server.reset({ authorized: false, wireShape: 'http-401' }); + const built = await connectToMock(server); + connection = built.connection; + const elicitationStart = jest.fn(async () => undefined); + const { manager: flowManager, createFlow } = unusedFlowManager(); + + const result = await built.manager.callTool({ + user: USER, + serverName: built.serverName, + serverConfig: streamableHttpConfig(server.url), + toolName: 'whoami', + provider: 'openai', + flowManager: flowManager as never, + elicitationStart: elicitationStart as never, + }); + + expect(JSON.stringify(result)).toContain('mock-user'); + expect(elicitationStart).not.toHaveBeenCalled(); + expect(createFlow).not.toHaveBeenCalled(); + expect(server.state.callCounts['whoami']).toBe(1); + }); + + describe.each(WIRE_SHAPES)('wire shape: %s', (wireShape) => { + it('emits the elicitation, retries exactly once, and returns the authorized result', async () => { + server.reset({ authorized: false, wireShape }); + const built = await connectToMock(server); + connection = built.connection; + + const elicitationStart = jest.fn(async () => undefined); + const { manager: flowManager, createFlow } = consentingFlowManager(server); + + const result = await callGetSecret(built.manager, server, built.serverName, { + flowManager, + elicitationStart, + }); + + // The authorization link + message reached the UI layer exactly once. + expect(elicitationStart).toHaveBeenCalledTimes(1); + expect(elicitationStart).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'url', + url: server.consentUrl, + message: expect.stringContaining('authorize'), + }), + ); + expect(createFlow).toHaveBeenCalledTimes(1); + + // Exactly one retry after consent — the initial call plus the retry. + expect(server.state.callCounts['get_secret']).toBe(2); + expect(JSON.stringify(result)).toContain('s3cr3t-payload'); + }); + + it('propagates the error and does NOT retry when consent fails', async () => { + server.reset({ authorized: false, wireShape }); + const built = await connectToMock(server); + connection = built.connection; + + const elicitationStart = jest.fn(async () => undefined); + const { manager: flowManager, createFlow } = rejectingFlowManager( + new Error('user cancelled'), + ); + + await expect( + callGetSecret(built.manager, server, built.serverName, { flowManager, elicitationStart }), + ).rejects.toThrow(/authorize/i); + + expect(elicitationStart).toHaveBeenCalledTimes(1); + expect(createFlow).toHaveBeenCalledTimes(1); + // Initial call only — the failed consent must not trigger a retry. + expect(server.state.callCounts['get_secret']).toBe(1); + }); + + it('rethrows the original error with no elicitation when elicitationStart is absent', async () => { + server.reset({ authorized: false, wireShape }); + const built = await connectToMock(server); + connection = built.connection; + + const { manager: flowManager, createFlow } = unusedFlowManager(); + + await expect( + callGetSecret(built.manager, server, built.serverName, { flowManager }), + ).rejects.toThrow(); + + // No elicitation machinery ran; the raw tool error surfaced directly. + expect(createFlow).not.toHaveBeenCalled(); + expect(server.state.callCounts['get_secret']).toBe(1); + }); + }); + + // The HTTP-wrapped shape is matched by scanning the SDK error's `.message` + // string, so the exact JSON byte layout matters. A gateway that pretty-prints + // (or otherwise inserts whitespace) yields `"code": -32042` with a space — + // extraction must still recognize it, otherwise callTool rethrows and the UI + // shows a generic gateway error even though authorization was available. + it('extracts a whitespaced HTTP-wrapped -32042 body (gateway serializer variance)', async () => { + server.reset({ authorized: false, wireShape: 'http-401', bodyFormat: 'pretty' }); + const built = await connectToMock(server); + connection = built.connection; + + const elicitationStart = jest.fn(async () => undefined); + const { manager: flowManager, createFlow } = consentingFlowManager(server); + + const result = await callGetSecret(built.manager, server, built.serverName, { + flowManager, + elicitationStart, + }); + + expect(elicitationStart).toHaveBeenCalledTimes(1); + expect(createFlow).toHaveBeenCalledTimes(1); + expect(server.state.callCounts['get_secret']).toBe(2); + expect(JSON.stringify(result)).toContain('s3cr3t-payload'); + }); + + // Regression for the spurious-reconnect bug: the -32042 arrives HTTP-wrapped + // (StreamableHTTPError.code === 401), which the transport error handler would + // otherwise mistake for an OAuth/connection failure and reconnect on — racing + // the in-band retry and, in prod, leaving a stale session (-32600). The live + // session must be left untouched. + it('does NOT emit oauthError or reconnect on a -32042 (request-scoped, session stays live)', async () => { + server.reset({ authorized: false, wireShape: 'http-401' }); + const built = await connectToMock(server); + connection = built.connection; + + const connectionStates: string[] = []; + built.connection.on('connectionChange', (state) => connectionStates.push(state)); + let oauthErrors = 0; + built.connection.on('oauthError', () => { + oauthErrors += 1; + }); + + const elicitationStart = jest.fn(async () => undefined); + const { manager: flowManager } = consentingFlowManager(server); + + const result = await callGetSecret(built.manager, server, built.serverName, { + flowManager, + elicitationStart, + }); + + expect(JSON.stringify(result)).toContain('s3cr3t-payload'); + // The elicitation error must not be classified as an OAuth/transport failure. + expect(oauthErrors).toBe(0); + expect(connectionStates).not.toContain('error'); + }); + + // Documents current -32600 handling (Datadog's observed production failure): a + // "Session not initialized" error on tools/call is NOT auto-recovered by + // callTool — no re-initialize, no retry — it surfaces to the caller. (Mirrored + // in MCPManager.callTool: only -32042 is caught for retry; every other error is + // rethrown, packages/api/src/mcp/MCPManager.ts:607-666.) + it('surfaces a -32600 session error with no auto-reinitialize/retry', async () => { + server.reset({ authorized: true }); + server.state.sessionErrorOnce = true; + const built = await connectToMock(server); + connection = built.connection; + + const elicitationStart = jest.fn(async () => undefined); + const { manager: flowManager } = unusedFlowManager(); + + await expect( + callGetSecret(built.manager, server, built.serverName, { flowManager, elicitationStart }), + ).rejects.toThrow(); + + // Exactly one attempt — callTool did not re-initialize and retry the session. + expect(server.state.callCounts['get_secret']).toBe(1); + expect(elicitationStart).not.toHaveBeenCalled(); + }); + + // GET /reset re-gates the mock (authorized=false) so a human can run repeated + // UI rounds without restarting the process — and, unlike /consent, it is safe + // to hit in smoke checks (it never grants access). + it('GET /reset sets authorized=false for a fresh round', async () => { + server.reset({ authorized: true }); + expect(server.state.authorized).toBe(true); + + const res = await fetch(server.resetUrl); + expect(res.status).toBe(200); + expect(await res.text()).toContain('Reset'); + expect(server.state.authorized).toBe(false); + }); +}); diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 87116b6a6e8..c243e92633d 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -5,7 +5,10 @@ import { fetch as undiciFetch, Agent, ProxyAgent } from 'undici'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; -import { ResourceListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + ElicitRequestSchema, + ResourceListChangedNotificationSchema, +} from '@modelcontextprotocol/sdk/types.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport, @@ -18,12 +21,14 @@ import type { Dispatcher, } from 'undici'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Agents } from 'librechat-data-provider'; import type { MCPOAuthTokens } from './oauth/types'; import type * as t from './types'; import { createSSRFSafeUndiciConnect, isSSRFTarget, resolveHostnameSSRF } from '~/auth'; import { runOutsideTracing } from '~/utils/tracing'; import { isAddressAllowed } from '~/auth/domain'; import { sanitizeUrlForLogging } from './utils'; +import { extractUrlElicitation } from './elicitation'; import { withTimeout } from '~/utils/promise'; import { mcpConfig } from './mcpConfig'; @@ -32,6 +37,17 @@ type ManagedDispatcher = Agent | ProxyAgent; type ParsedIP = { version: 4 | 6; bits: 32 | 128; value: bigint }; type MCPTool = MCPListToolsResult['tools'][number]; +/** + * Params delivered to a {@link MCPConnection.setElicitationHandler} handler for a + * server-initiated `elicitation/create` request. The form variant (mode absent or + * `'form'`) carries the JSON-schema form; the `'url'` variant carries the out-of-band + * authorization link. Mirrors the SDK's `ElicitRequestParamsSchema` union, restated + * here so `requestedSchema` uses our shared `Agents.ElicitationSchema` shape. + */ +type ElicitationCreateParams = + | { mode?: 'form'; message: string; requestedSchema: Agents.ElicitationSchema } + | { mode: 'url'; message: string; elicitationId: string; url: string }; + const BIGINT_ZERO = BigInt(0); const BIGINT_ONE = BigInt(1); const BIGINT_EIGHT = BigInt(8); @@ -1139,6 +1155,8 @@ export class MCPConnection extends EventEmitter { private lastPingTime: number; private lastConnectionCheckAt: number = 0; private oauthTokens?: MCPOAuthTokens | null; + /** Identity of the currently registered elicitation handler; see {@link setElicitationHandler}. */ + private elicitationHandlerToken?: symbol; private requestHeaders?: Record | null; private oauthRequired = false; private oauthRecovery = false; @@ -1277,7 +1295,12 @@ export class MCPConnection extends EventEmitter { version: '1.2.3', }, { - capabilities: {}, + /** Declares support for both elicitation wire modes (spec 2025-11-25): + * `form` (2025-06-18 `elicitation/create` with a JSON-schema form) and + * `url` (out-of-band authorization link, either via `elicitation/create` + * with `mode: 'url'`, or the -32042 `UrlElicitationRequired` exception + * path on `tools/call`, which doesn't consult this capability at all). */ + capabilities: { elicitation: { form: {}, url: {} } }, }, ); @@ -2098,6 +2121,26 @@ export class MCPConnection extends EventEmitter { return; } + /** + * A -32042 `UrlElicitationRequired` (the gateway's per-tool authorization + * signal, delivered HTTP-wrapped so `.code` is the 401 status) is NOT a + * transport/session failure: the gateway responded and the session is + * alive. `MCPManager.callTool` handles it in-band — surface the link, await + * consent, then retry the SAME session. Classifying it as an OAuth error + * here (`isOAuthError` matches the 401) would emit `oauthError` and + * `connectionChange: 'error'`, triggering a spurious background + * reconnection that races with — and can invalidate — that retry. In + * production that reconnection abandons on `-32002 insufficient_scope`, + * leaving a stale session whose retry then fails with `-32600 Session not + * initialized`. Leave the live session untouched. + */ + if (extractUrlElicitation(error)) { + logger.debug( + `${this.getLogPrefix()} tools/call URL elicitation (-32042); handled in-band by callTool, not reconnecting`, + ); + return; + } + const { message: errorMessage, code: errorCode, @@ -2416,6 +2459,41 @@ export class MCPConnection extends EventEmitter { this.oauthTokens = tokens; } + /** + * Registers the handler for server-initiated `elicitation/create` requests + * (both `mode: 'form'` and `mode: 'url'`). Does NOT handle the -32042 + * `UrlElicitationRequired` exception path — that arrives as an error on the + * `tools/call` response itself and is handled in `MCPManager.callTool`. + * + * Returns a disposer that unregisters the handler; callers must invoke it once + * the originating `tools/call` settles so a cached connection can't leak a stale + * per-call closure into a later, unrelated request. + * + * Concurrent calls on one cached connection overwrite each other's handler + * (`elicitation/create` carries no call correlation, so last-writer-wins is the + * best the protocol allows) — but disposal is token-guarded so an earlier call + * settling never tears down a later call's live handler. + */ + public setElicitationHandler( + handler: (params: ElicitationCreateParams) => Promise<{ + action: 'accept' | 'decline' | 'cancel'; + content?: Record; + }>, + ): () => void { + const token = Symbol('elicitation-handler'); + this.elicitationHandlerToken = token; + this.client.setRequestHandler(ElicitRequestSchema, async (request) => + handler(request.params as ElicitationCreateParams), + ); + return () => { + if (this.elicitationHandlerToken !== token) { + return; + } + this.elicitationHandlerToken = undefined; + this.client.removeRequestHandler(ElicitRequestSchema.shape.method.value); + }; + } + /** * Check if this connection is stale compared to config update time. * A connection is stale if it was created before the config was updated. diff --git a/packages/api/src/mcp/elicitation.ts b/packages/api/src/mcp/elicitation.ts new file mode 100644 index 00000000000..02fdf8836e2 --- /dev/null +++ b/packages/api/src/mcp/elicitation.ts @@ -0,0 +1,194 @@ +import { randomUUID } from 'crypto'; +import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + +/** A single URL-mode elicitation as carried by a -32042 `UrlElicitationRequired` + * error's `data.elicitations` (MCP spec 2025-11-25). */ +export interface UrlElicitation { + mode?: string; + message: string; + url: string; + elicitationId: string; +} + +/** + * Extracts the first URL elicitation from a failed `tools/call`, handling both + * wire shapes a -32042 can arrive in: + * + * 1. A protocol-level JSON-RPC error response — the SDK surfaces it as an + * `McpError`/`UrlElicitationRequiredError` with `code === -32042` and + * `data.elicitations`. + * 2. An HTTP-level rejection — AgentCore Gateway returns JSON-RPC errors with a + * non-2xx status, so the SDK's streamable-HTTP transport never parses the + * body and instead throws a `StreamableHTTPError` whose `code` is the HTTP + * status and whose message embeds the raw body + * (`"Error POSTing to endpoint: {\"jsonrpc\":...,\"error\":{\"code\":-32042,...}}"`). + * + * Returns `null` when the error is not a URL elicitation in either shape. + */ +export function extractUrlElicitation(error: unknown): UrlElicitation | null { + if (!error || typeof error !== 'object') { + return null; + } + + const { code, data, message } = error as { + code?: unknown; + data?: { elicitations?: UrlElicitation[] }; + message?: unknown; + }; + + if (code === ErrorCode.UrlElicitationRequired) { + return data?.elicitations?.[0] ?? null; + } + + // Cheap pre-filter on the bare error number (not `"code":-32042`) so gateway + // JSON with whitespace/key-order variance — e.g. a pretty-printed + // `"code": -32042` — still gets parsed; the JSON.parse + numeric-code check + // below is what actually validates the shape. + if (typeof message !== 'string' || !message.includes(String(ErrorCode.UrlElicitationRequired))) { + return null; + } + const braceIndex = message.indexOf('{'); + if (braceIndex === -1) { + return null; + } + const body = message.slice(braceIndex); + try { + const parsed = JSON.parse(body) as { + error?: { code?: number; data?: { elicitations?: UrlElicitation[] } }; + }; + if (parsed.error?.code !== ErrorCode.UrlElicitationRequired) { + return null; + } + return parsed.error.data?.elicitations?.[0] ?? null; + } catch { + return null; + } +} +import type { FlowStateManager } from '~/flow/manager'; + +/** + * Terminal actions a client can post to `POST /api/mcp/elicitation/:flowId`. + * `accept`/`decline`/`cancel` mirror the MCP SDK's `ElicitResultSchema.action` + * (2025-06-18 form-mode elicitation). `complete` is the URL-exception (-32042) + * "I've authorized — continue" signal that has no direct SDK analog, since that + * path never sends an `elicitation/create` response — it just resumes a + * suspended `tools/call`. + */ +export type ElicitationFlowAction = 'accept' | 'decline' | 'cancel' | 'complete'; + +export interface ElicitationFlowResult { + action: ElicitationFlowAction; + content?: Record; +} + +/** + * Re-views the process-wide {@link FlowStateManager} singleton — statically typed for OAuth + * tokens at its main call sites — as an elicitation-flow manager. The manager stores payloads + * keyed at runtime by (flowId, flow type); the payload shape for an `mcp_elicit` flow is + * fixed by the flow type, not the class generic, so the generic is erased here. This is the + * one audited place that assertion lives, so callers never scatter `as unknown as`. + */ +export function asElicitationFlowManager( + flowManager: unknown, +): FlowStateManager { + return flowManager as FlowStateManager; +} + +/** + * True when a completed elicitation flow's action counts as "proceed" — either + * the 2025-06-18 form-mode `accept`, or the URL-exception `complete`. + */ +export function isElicitationSuccess(action: ElicitationFlowAction | undefined): boolean { + return action === 'accept' || action === 'complete'; +} + +/** + * Maps a flow result's action onto the MCP SDK's `ElicitResultSchema.action` + * enum (`accept` | `decline` | `cancel`), which has no `complete` member — the + * URL-exception-only "I've authorized, continue" signal is treated as `accept` + * for protocol responses. + */ +export function toElicitResultAction( + action: ElicitationFlowAction, +): 'accept' | 'decline' | 'cancel' { + if (action === 'complete') { + return 'accept'; + } + return action; +} + +/** + * Generates a flow ID for an MCP elicitation flow (a `mode: 'form'|'url'` + * `elicitation/create` request, or a -32042 URL-exception retry). + * + * Unlike OAuth flow IDs (`MCPOAuthHandler.generateFlowId`, one per user+server), + * elicitation flows are scoped per tool invocation — concurrent calls to the + * same server must not collide — and the userId is embedded directly so the + * completion route can enforce per-user ownership the same way OAuth flow + * routes do (see `canAccessOAuthFlow` in `api/server/routes/mcp.js`). + * + * Every variable segment is URI-encoded so a `:` inside any of them (server and + * tool names are config/user-derived) can't skew the fields {@link + * parseElicitationFlowId} reads back out. + */ +export function generateElicitationFlowId( + userId: string, + serverName: string, + toolName: string, + tenantId?: string, +): string { + const flowId = `${encodeURIComponent(userId)}:${encodeURIComponent(serverName)}:${encodeURIComponent(toolName)}:${randomUUID()}`; + if (!tenantId) { + return flowId; + } + return `tenant:${encodeURIComponent(tenantId)}:${flowId}`; +} + +export interface ParsedElicitationFlowId { + userId: string; + serverName: string; + toolName: string; + nonce: string; + tenantId?: string; +} + +/** Inverse of {@link generateElicitationFlowId}, used by the completion route to + * verify the requesting user owns the flow before resolving it. */ +export function parseElicitationFlowId(flowId: string): ParsedElicitationFlowId | null { + const parts = flowId.split(':'); + let offset = 0; + let tenantId: string | undefined; + + if (parts[0] === 'tenant') { + if (parts.length < 6 || !parts[1]) { + return null; + } + try { + tenantId = decodeURIComponent(parts[1]); + } catch { + return null; + } + offset = 2; + } + + if (parts.length < offset + 4) { + return null; + } + + const [rawUserId, rawServerName, rawToolName, nonce] = parts.slice(offset, offset + 4); + if (!rawUserId || !rawServerName || !rawToolName || !nonce) { + return null; + } + + try { + return { + userId: decodeURIComponent(rawUserId), + serverName: decodeURIComponent(rawServerName), + toolName: decodeURIComponent(rawToolName), + nonce, + tenantId, + }; + } catch { + return null; + } +} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 326e60f6443..cdfd3232291 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -233,6 +233,9 @@ export const cancelMCPOAuth = (serverName: string) => { export const mcpOAuthBind = (serverName: string) => `${BASE_URL}/api/mcp/${serverName}/oauth/bind`; +export const mcpElicitationRespond = (flowId: string) => + `${BASE_URL}/api/mcp/elicitation/${encodeURIComponent(flowId)}`; + export const actionOAuthBind = (actionId: string) => `${BASE_URL}/api/actions/${actionId}/oauth/bind`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 80e55551706..9d78bca0b95 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -235,6 +235,22 @@ export function cancelMCPOAuth(serverName: string): Promise; + }, +): Promise<{ ok: boolean }> => { + return request.post(endpoints.mcpElicitationRespond(flowId), body); +}; + /* Config */ export type StartupConfigOptions = { diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 0dc96fb6ccf..e1feedde61e 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -56,6 +56,7 @@ export namespace Agents { | MessageContentInputAudio | SummaryContentPart | ToolCallContent + | ElicitationContent // eslint-disable-next-line @typescript-eslint/no-explicit-any | (Record & { type?: ContentTypes | string }) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -100,6 +101,69 @@ export namespace Agents { tool_call?: ToolCall; }; + /** + * MCP elicitation. Covers both wire mechanisms: + * - "form": a server `elicitation/create` request (spec 2025-06-18) answered with + * `{ action: 'accept' | 'decline' | 'cancel', content? }`. + * - "url": either a `mode: 'url'` `elicitation/create` request, OR the URL-exception + * path where a `tools/call` response errors with JSON-RPC code -32042 + * (`ErrorCode.UrlElicitationRequired`) carrying `data.elicitations[]`. Both surface + * the same authorization-link card; the client always resolves them via + * `POST /api/mcp/elicitation/:flowId`. + */ + export type ElicitationMode = 'form' | 'url'; + + export type ElicitationPropertySchema = { + type: 'string' | 'number' | 'integer' | 'boolean'; + title?: string; + description?: string; + enum?: string[]; + default?: string | number | boolean; + minLength?: number; + maxLength?: number; + minimum?: number; + maximum?: number; + }; + + export type ElicitationSchema = { + type: 'object'; + properties: Record; + required?: string[]; + }; + + /** Terminal resolution states a client can post back to `/api/mcp/elicitation/:flowId`. + * `accept`/`decline`/`cancel` mirror the SDK's `ElicitResultSchema.action` (form mode); + * `complete` is the URL-exception (-32042) "I've authorized, continue" signal — treated + * as equivalent to `accept` when resuming the flow. */ + export type ElicitationAction = 'accept' | 'decline' | 'cancel' | 'complete'; + + export type ElicitationContent = { + type: ContentTypes.ELICITATION; + elicitation: { + flowId: string; + mode: ElicitationMode; + message: string; + /** requesting MCP server name, for the card header identity line */ + serverName?: string; + /** requesting MCP tool name, for the card header identity line */ + toolName?: string; + /** form mode only */ + requestedSchema?: ElicitationSchema; + /** url mode only: the authorization/consent page to open */ + url?: string; + /** Set once the card has been resolved (locally, or replayed from persisted history) */ + action?: ElicitationAction; + content?: Record; + }; + }; + + /** Wire payload of the `on_elicitation` SSE event. */ + export type ElicitationEvent = { + id: string; + runId?: string; + elicitation: ElicitationContent['elicitation']; + }; + /** * A chunk of a tool call (e.g., as part of a stream). * When merging ToolCallChunks (e.g., via AIMessageChunk.__add__), diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index b222c718edd..5f40692ff86 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -593,7 +593,8 @@ export type TMessageContentParts = | (Agents.AgentUpdate & ContentMetadata) | (Agents.MessageContentImageUrl & ContentMetadata) | (Agents.MessageContentVideoUrl & ContentMetadata) - | (Agents.MessageContentInputAudio & ContentMetadata); + | (Agents.MessageContentInputAudio & ContentMetadata) + | (Agents.ElicitationContent & ContentMetadata); export type StreamContentData = TMessageContentParts & { /** The index of the current content part */ diff --git a/packages/data-provider/src/types/runs.ts b/packages/data-provider/src/types/runs.ts index 8e1033c07fe..2647513cd0d 100644 --- a/packages/data-provider/src/types/runs.ts +++ b/packages/data-provider/src/types/runs.ts @@ -11,6 +11,7 @@ export enum ContentTypes { INPUT_AUDIO = 'input_audio', AGENT_UPDATE = 'agent_update', SUMMARY = 'summary', + ELICITATION = 'elicitation', ERROR = 'error', } @@ -40,6 +41,7 @@ export enum StepEvents { ON_SUMMARIZE_DELTA = 'on_summarize_delta', ON_SUMMARIZE_COMPLETE = 'on_summarize_complete', ON_SUBAGENT_UPDATE = 'on_subagent_update', + ON_ELICITATION = 'on_elicitation', } /** Token-tracking event names streamed to the client (separate from StepEvents dispatch). */