feat(mcp): elicitation support (#5965)

This commit is contained in:
Alex Hancock
2025-12-05 21:29:59 -05:00
committed by GitHub
parent 1db40709bb
commit 06155ce0ca
24 changed files with 992 additions and 22 deletions
+44
View File
@@ -2428,6 +2428,50 @@
"type": "string"
}
}
},
{
"type": "object",
"required": [
"id",
"message",
"requested_schema",
"actionType"
],
"properties": {
"actionType": {
"type": "string",
"enum": [
"elicitation"
]
},
"id": {
"type": "string"
},
"message": {
"type": "string"
},
"requested_schema": {}
}
},
{
"type": "object",
"required": [
"id",
"user_data",
"actionType"
],
"properties": {
"actionType": {
"type": "string",
"enum": [
"elicitationResponse"
]
},
"id": {
"type": "string"
},
"user_data": {}
}
}
],
"discriminator": {
+9
View File
@@ -14,6 +14,15 @@ export type ActionRequiredData = {
id: string;
prompt?: string | null;
toolName: string;
} | {
actionType: 'elicitation';
id: string;
message: string;
requested_schema: unknown;
} | {
actionType: 'elicitationResponse';
id: string;
user_data: unknown;
};
export type AddExtensionRequest = {
+2
View File
@@ -96,6 +96,7 @@ function BaseChatContent({
messages,
chatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
sessionLoadError,
setRecipeUserParams,
@@ -274,6 +275,7 @@ function BaseChatContent({
isStreamingMessage={chatState !== ChatState.Idle}
onRenderingComplete={handleRenderingComplete}
onMessageUpdate={onMessageUpdate}
submitElicitationResponse={submitElicitationResponse}
/>
</>
);
@@ -0,0 +1,74 @@
import { useState } from 'react';
import { ActionRequired } from '../api';
import JsonSchemaForm from './ui/JsonSchemaForm';
import type { JsonSchema } from './ui/JsonSchemaForm';
interface ElicitationRequestProps {
isCancelledMessage: boolean;
isClicked: boolean;
actionRequiredContent: ActionRequired & { type: 'actionRequired' };
onSubmit: (elicitationId: string, userData: Record<string, unknown>) => void;
}
export default function ElicitationRequest({
isCancelledMessage,
isClicked,
actionRequiredContent,
onSubmit,
}: ElicitationRequestProps) {
const [submitted, setSubmitted] = useState(isClicked);
if (actionRequiredContent.data.actionType !== 'elicitation') {
return null;
}
const { id: elicitationId, message, requested_schema } = actionRequiredContent.data;
const handleSubmit = (formData: Record<string, unknown>) => {
setSubmitted(true);
onSubmit(elicitationId, formData);
};
if (isCancelledMessage) {
return (
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
Information request was cancelled.
</div>
);
}
if (submitted) {
return (
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Information submitted</span>
</div>
</div>
);
}
return (
<div className="flex flex-col">
<div className="goose-message-content bg-background-muted rounded-2xl rounded-b-none px-4 py-2 text-textStandard">
{message || 'Goose needs some information from you.'}
</div>
<div className="goose-message-content bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 py-3">
<JsonSchemaForm
schema={requested_schema as JsonSchema}
onSubmit={handleSubmit}
submitLabel="Submit"
/>
</div>
</div>
);
}
@@ -9,10 +9,12 @@ import {
getToolRequests,
getToolResponses,
getToolConfirmationContent,
getElicitationContent,
NotificationEvent,
} from '../types/message';
import { Message, confirmToolAction } from '../api';
import ToolCallConfirmation from './ToolCallConfirmation';
import ElicitationRequest from './ElicitationRequest';
import MessageCopyLink from './MessageCopyLink';
import { cn } from '../utils';
import { identifyConsecutiveToolCalls, shouldHideTimestamp } from '../utils/toolCallChaining';
@@ -28,6 +30,10 @@ interface GooseMessageProps {
toolCallNotifications: Map<string, NotificationEvent[]>;
append: (value: string) => void;
isStreaming?: boolean; // Whether this message is currently being streamed
submitElicitationResponse?: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
}
export default function GooseMessage({
@@ -38,6 +44,7 @@ export default function GooseMessage({
toolCallNotifications,
append,
isStreaming = false,
submitElicitationResponse,
}: GooseMessageProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const handledToolConfirmations = useRef<Set<string>>(new Set());
@@ -69,12 +76,14 @@ export default function GooseMessage({
const toolRequests = getToolRequests(message);
const messageIndex = messages.findIndex((msg) => msg.id === message.id);
const toolConfirmationContent = getToolConfirmationContent(message);
const elicitationContent = getElicitationContent(message);
const toolCallChains = useMemo(() => identifyConsecutiveToolCalls(messages), [messages]);
const hideTimestamp = useMemo(
() => shouldHideTimestamp(messageIndex, toolCallChains),
[messageIndex, toolCallChains]
);
const hasToolConfirmation = toolConfirmationContent !== undefined;
const hasElicitation = elicitationContent !== undefined;
const toolResponsesMap = useMemo(() => {
const responseMap = new Map();
@@ -219,6 +228,15 @@ export default function GooseMessage({
actionRequiredContent={toolConfirmationContent}
/>
)}
{hasElicitation && submitElicitationResponse && (
<ElicitationRequest
isCancelledMessage={messageIndex == messageHistoryIndex - 1}
isClicked={messageIndex < messageHistoryIndex}
actionRequiredContent={elicitationContent}
onSubmit={submitElicitationResponse}
/>
)}
</div>
</div>
);
@@ -38,6 +38,10 @@ interface ProgressiveMessageListProps {
isStreamingMessage?: boolean; // Whether messages are currently being streamed
onMessageUpdate?: (messageId: string, newContent: string) => void;
onRenderingComplete?: () => void; // Callback when all messages are rendered
submitElicitationResponse?: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
}
export default function ProgressiveMessageList({
@@ -53,6 +57,7 @@ export default function ProgressiveMessageList({
isStreamingMessage = false, // Whether messages are currently being streamed
onMessageUpdate,
onRenderingComplete,
submitElicitationResponse,
}: ProgressiveMessageListProps) {
const [renderedCount, setRenderedCount] = useState(() => {
// Initialize with either all messages (if small) or first batch (if large)
@@ -223,6 +228,7 @@ export default function ProgressiveMessageList({
index === messagesToRender.length - 1 &&
message.role === 'assistant'
}
submitElicitationResponse={submitElicitationResponse}
/>
)}
</div>
@@ -240,6 +246,7 @@ export default function ProgressiveMessageList({
isStreamingMessage,
onMessageUpdate,
toolCallChains,
submitElicitationResponse,
]);
return (
@@ -20,6 +20,8 @@ const toolConfirmationState = new Map<
}
>();
type ToolConfirmationData = Extract<ActionRequired['data'], { actionType: 'toolConfirmation' }>;
interface ToolConfirmationProps {
sessionId: string;
isCancelledMessage: boolean;
@@ -33,7 +35,8 @@ export default function ToolConfirmation({
isClicked,
actionRequiredContent,
}: ToolConfirmationProps) {
const { id: toolConfirmationId, toolName, prompt } = actionRequiredContent.data;
const data = actionRequiredContent.data as ToolConfirmationData;
const { id: toolConfirmationId, toolName, prompt } = data;
// Check if we have a stored state for this tool confirmation
const storedState = toolConfirmationState.get(toolConfirmationId);
@@ -0,0 +1,258 @@
import React, { useState, useCallback } from 'react';
import { Input } from './input';
import { Button } from './button';
interface JsonSchemaProperty {
type?: string;
description?: string;
default?: unknown;
enum?: string[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
}
export interface JsonSchema {
type?: string;
properties?: Record<string, JsonSchemaProperty>;
required?: string[];
title?: string;
description?: string;
}
interface JsonSchemaFormProps {
schema: JsonSchema;
onSubmit: (data: Record<string, unknown>) => void;
onCancel?: () => void;
submitLabel?: string;
cancelLabel?: string;
disabled?: boolean;
}
export default function JsonSchemaForm({
schema,
onSubmit,
onCancel,
submitLabel = 'Submit',
cancelLabel = 'Cancel',
disabled = false,
}: JsonSchemaFormProps) {
const [formData, setFormData] = useState<Record<string, unknown>>(() => {
const initial: Record<string, unknown> = {};
if (schema.properties) {
for (const [key, prop] of Object.entries(schema.properties)) {
if (prop.default !== undefined) {
initial[key] = prop.default;
} else if (prop.type === 'boolean') {
initial[key] = false;
} else if (prop.type === 'number' || prop.type === 'integer') {
initial[key] = prop.minimum ?? 0;
} else {
initial[key] = '';
}
}
}
return initial;
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validateField = useCallback(
(key: string, value: unknown): string | null => {
const prop = schema.properties?.[key];
if (!prop) return null;
const isRequired = schema.required?.includes(key);
if (isRequired && (value === '' || value === null || value === undefined)) {
return 'This field is required';
}
if (prop.type === 'string' && typeof value === 'string') {
if (!isRequired && value === '') return null;
if (prop.minLength !== undefined && value.length < prop.minLength) {
return `Minimum length is ${prop.minLength}`;
}
if (prop.maxLength !== undefined && value.length > prop.maxLength) {
return `Maximum length is ${prop.maxLength}`;
}
}
if ((prop.type === 'number' || prop.type === 'integer') && typeof value === 'number') {
if (prop.minimum !== undefined && value < prop.minimum) {
return `Minimum value is ${prop.minimum}`;
}
if (prop.maximum !== undefined && value > prop.maximum) {
return `Maximum value is ${prop.maximum}`;
}
}
return null;
},
[schema]
);
const handleChange = useCallback(
(key: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [key]: value }));
const error = validateField(key, value);
setErrors((prev) => {
if (error) {
return { ...prev, [key]: error };
}
const newErrors = { ...prev };
delete newErrors[key];
return newErrors;
});
},
[validateField]
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (schema.properties) {
for (const key of Object.keys(schema.properties)) {
const error = validateField(key, formData[key]);
if (error) {
newErrors[key] = error;
}
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(formData);
},
[formData, onSubmit, schema.properties, validateField]
);
const renderField = (key: string, prop: JsonSchemaProperty) => {
const value = formData[key];
const error = errors[key];
const isRequired = schema.required?.includes(key);
if (prop.enum) {
return (
<select
id={key}
value={String(value ?? '')}
onChange={(e) => handleChange(key, e.target.value)}
disabled={disabled}
className="flex h-9 w-full rounded-md border focus:border-border-strong hover:border-border-strong bg-background-default px-3 py-1 text-base transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
>
{!isRequired && <option value="">Select...</option>}
{prop.enum.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
}
if (prop.type === 'boolean') {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
id={key}
checked={Boolean(value)}
onChange={(e) => handleChange(key, e.target.checked)}
disabled={disabled}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-textStandard">{prop.description || key}</span>
</label>
);
}
if (prop.type === 'number' || prop.type === 'integer') {
return (
<Input
type="number"
id={key}
value={String(value ?? '')}
onChange={(e) => {
const numValue =
prop.type === 'integer' ? parseInt(e.target.value, 10) : parseFloat(e.target.value);
handleChange(key, isNaN(numValue) ? '' : numValue);
}}
min={prop.minimum}
max={prop.maximum}
step={prop.type === 'integer' ? 1 : 'any'}
disabled={disabled}
className={error ? 'border-red-500' : ''}
/>
);
}
return (
<Input
type="text"
id={key}
value={String(value ?? '')}
onChange={(e) => handleChange(key, e.target.value)}
minLength={prop.minLength}
maxLength={prop.maxLength}
disabled={disabled}
className={error ? 'border-red-500' : ''}
/>
);
};
if (!schema.properties || Object.keys(schema.properties).length === 0) {
return <div className="text-textSubtle text-sm">No fields to display</div>;
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{Object.entries(schema.properties).map(([key, prop]) => {
const isRequired = schema.required?.includes(key);
const error = errors[key];
if (prop.type === 'boolean') {
return (
<div key={key} className="flex flex-col gap-1">
{renderField(key, prop)}
{error && <span className="text-red-500 text-xs">{error}</span>}
</div>
);
}
return (
<div key={key} className="flex flex-col gap-1">
<label htmlFor={key} className="text-sm font-medium text-textStandard">
{key}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{prop.description && prop.type !== 'boolean' && (
<span className="text-xs text-textSubtle">{prop.description}</span>
)}
{renderField(key, prop)}
{error && <span className="text-red-500 text-xs">{error}</span>}
</div>
);
})}
<div className="flex gap-2 mt-2">
<Button type="submit" disabled={disabled}>
{submitLabel}
</Button>
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel} disabled={disabled}>
{cancelLabel}
</Button>
)}
</div>
</form>
);
}
+56 -1
View File
@@ -14,6 +14,7 @@ import {
import {
createUserMessage,
createElicitationResponseMessage,
getCompactingMessage,
getThinkingMessage,
NotificationEvent,
@@ -33,6 +34,10 @@ interface UseChatStreamReturn {
messages: Message[];
chatState: ChatState;
handleSubmit: (userMessage: string) => Promise<void>;
submitElicitationResponse: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
@@ -89,7 +94,12 @@ async function streamFromResponse(
(content) => content.type === 'toolConfirmationRequest'
);
if (hasToolConfirmation) {
const hasElicitation = msg.content.some(
(content) =>
content.type === 'actionRequired' && content.data.actionType === 'elicitation'
);
if (hasToolConfirmation || hasElicitation) {
updateChatState(ChatState.WaitingForUserInput);
} else if (getCompactingMessage(msg)) {
updateChatState(ChatState.Compacting);
@@ -312,6 +322,50 @@ export function useChatStream({
[sessionId, session, chatState, updateMessages, updateNotifications, onFinish]
);
const submitElicitationResponse = useCallback(
async (elicitationId: string, userData: Record<string, unknown>) => {
if (!session || chatState === ChatState.LoadingConversation) {
return;
}
const responseMessage = createElicitationResponseMessage(elicitationId, userData);
const currentMessages = [...messagesRef.current, responseMessage];
updateMessages(currentMessages);
setChatState(ChatState.Streaming);
setNotifications([]);
abortControllerRef.current = new AbortController();
try {
const { stream } = await reply({
body: {
session_id: sessionId,
messages: currentMessages,
},
throwOnError: true,
signal: abortControllerRef.current.signal,
});
await streamFromResponse(
stream,
currentMessages,
updateMessages,
setTokenState,
setChatState,
updateNotifications,
onFinish
);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Silently handle abort
} else {
onFinish('Submit error: ' + errorMessage(error));
}
}
},
[sessionId, session, chatState, updateMessages, updateNotifications, onFinish]
);
const setRecipeUserParams = useCallback(
async (user_recipe_values: Record<string, string>) => {
if (session) {
@@ -437,6 +491,7 @@ export function useChatStream({
session: maybe_cached_session,
chatState,
handleSubmit,
submitElicitationResponse,
stopStreaming,
setRecipeUserParams,
tokenState,
+31
View File
@@ -17,6 +17,28 @@ export function createUserMessage(text: string): Message {
};
}
export function createElicitationResponseMessage(
elicitationId: string,
userData: Record<string, unknown>
): Message {
return {
id: generateMessageId(),
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'actionRequired',
data: {
actionType: 'elicitationResponse',
id: elicitationId,
user_data: userData,
},
},
],
metadata: { userVisible: false, agentVisible: true },
};
}
export function generateMessageId(): string {
return Math.random().toString(36).substring(2, 10);
}
@@ -51,6 +73,15 @@ export function getToolConfirmationContent(
);
}
export function getElicitationContent(
message: Message
): (ActionRequired & { type: 'actionRequired' }) | undefined {
return message.content.find(
(content): content is ActionRequired & { type: 'actionRequired' } =>
content.type === 'actionRequired' && content.data.actionType === 'elicitation'
);
}
export function hasCompletedToolCalls(message: Message): boolean {
const toolRequests = getToolRequests(message);
return toolRequests.length > 0;