From 41d4e5e04df64e19bc8ba037bbae5718ecf5f626 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 13 Jan 2026 16:34:32 +0000 Subject: [PATCH 1/3] feat(mcp): add elicitation request handling Add support for MCP elicitation requests across the codebase: - Add elicitation store to sync context with requested/completed/rejected event handlers - Add ElicitationPrompt component to session UI with priority after permissions and questions - Register elicitation identifier prefix "eli" - Implement elicitation request handler in MCP client with form capabilities - Add /mcp/elicitation routes for list, reply, and reject operations - Generate SDK types --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 23 + .../cmd/tui/routes/session/elicitation.tsx | 433 +++++++++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 11 +- packages/opencode/src/id/id.ts | 1 + packages/opencode/src/mcp/elicitation.ts | 313 +++++++++ packages/opencode/src/mcp/index.ts | 63 +- packages/opencode/src/server/elicitation.ts | 104 +++ packages/opencode/src/server/server.ts | 2 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 610 +++++++++++------- packages/sdk/js/src/v2/gen/types.gen.ts | 185 ++++++ 10 files changed, 1481 insertions(+), 264 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/elicitation.tsx create mode 100644 packages/opencode/src/mcp/elicitation.ts create mode 100644 packages/opencode/src/server/elicitation.ts diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0edc911344c..61010ab7fa4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -18,6 +18,7 @@ import type { ProviderAuthMethod, VcsInfo, } from "@opencode-ai/sdk/v2" +import type { Elicitation } from "@/mcp/elicitation" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" @@ -46,6 +47,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ question: { [sessionID: string]: QuestionRequest[] } + elicitation: { + [id: string]: Elicitation.Request + } config: Config session: Session[] session_status: { @@ -85,6 +89,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: [], permission: {}, question: {}, + elicitation: {}, command: [], provider: [], provider_default: {}, @@ -304,6 +309,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("vcs", { branch: event.properties.branch }) break } + + case "mcp.elicitation.requested": { + const request = event.properties + setStore("elicitation", request.id, request) + break + } + + case "mcp.elicitation.completed": + case "mcp.elicitation.rejected": { + const { id } = event.properties + setStore( + "elicitation", + produce((draft) => { + delete draft[id] + }), + ) + break + } } }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/elicitation.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/elicitation.tsx new file mode 100644 index 00000000000..441732f4c93 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/elicitation.tsx @@ -0,0 +1,433 @@ +import { createStore } from "solid-js/store" +import { createMemo, For, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" +import { useKeybind } from "../../context/keybind" +import { useTheme } from "../../context/theme" +import type { Elicitation } from "@/mcp/elicitation" +import { SplitBorder } from "../../component/border" +import { useTextareaKeybindings } from "../../component/textarea-keybindings" +import { useDialog } from "../../ui/dialog" +import { useSDK } from "../../context/sdk" + +// Field type derived from schema +type FieldType = "string" | "number" | "boolean" | "enum" | "multiselect" + +interface FormField { + key: string + type: FieldType + title: string + description?: string + required: boolean + default?: string | number | boolean | string[] + // For enum/multiselect + options?: Array<{ value: string; label: string }> + // For validation + format?: string + minLength?: number + maxLength?: number + minimum?: number + maximum?: number +} + +function schemaToFields(schema: Elicitation.RequestedSchema): FormField[] { + const fields: FormField[] = [] + const required = new Set(schema.required ?? []) + + for (const [key, prop] of Object.entries(schema.properties)) { + const field: FormField = { + key, + type: "string", + title: prop.title ?? key, + description: prop.description, + required: required.has(key), + default: prop.default as string | number | boolean | string[] | undefined, + } + + if (prop.type === "string") { + // Check for enum types + if (prop.oneOf) { + field.type = "enum" + field.options = prop.oneOf.map((o) => ({ value: o.const, label: o.title })) + } else if (prop.enum) { + field.type = "enum" + field.options = prop.enum.map((v, i) => ({ + value: v, + label: prop.enumNames?.[i] ?? v, + })) + } else { + field.type = "string" + field.format = prop.format + field.minLength = prop.minLength + field.maxLength = prop.maxLength + } + } else if (prop.type === "number" || prop.type === "integer") { + field.type = "number" + field.minimum = prop.minimum + field.maximum = prop.maximum + } else if (prop.type === "boolean") { + field.type = "boolean" + } else if (prop.type === "array") { + field.type = "multiselect" + if (prop.items.anyOf) { + field.options = prop.items.anyOf.map((o) => ({ value: o.const, label: o.title })) + } else if (prop.items.enum) { + field.options = prop.items.enum.map((v) => ({ value: v, label: v })) + } + } + + fields.push(field) + } + + return fields +} + +export function ElicitationPrompt(props: { request: Elicitation.Request }) { + const sdk = useSDK() + const { theme } = useTheme() + const keybind = useKeybind() + const bindings = useTextareaKeybindings() + const dialog = useDialog() + + const fields = createMemo(() => schemaToFields(props.request.requestedSchema)) + + const [store, setStore] = createStore({ + selectedField: 0, + values: {} as Record, + editing: false, + selectedOption: 0, // For enum/multiselect navigation + }) + + let textarea: TextareaRenderable | undefined + + const currentField = createMemo(() => fields()[store.selectedField]) + + // Initialize default values + const initDefaults = () => { + const defaults: Record = {} + for (const field of fields()) { + if (field.default !== undefined) { + defaults[field.key] = field.default + } else if (field.type === "boolean") { + defaults[field.key] = false + } else if (field.type === "multiselect") { + defaults[field.key] = [] + } + } + setStore("values", defaults) + } + + // Initialize on mount + if (fields().length > 0) { + initDefaults() + } + + async function submit() { + // Validate required fields + for (const field of fields()) { + if (field.required) { + const value = store.values[field.key] + if (value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) { + // TODO: Show validation error + return + } + } + } + + await sdk.client.mcp.elicitation.reply({ + id: props.request.id, + content: store.values, + }) + } + + async function reject(action: "decline" | "cancel" = "cancel") { + await sdk.client.mcp.elicitation.reject({ + id: props.request.id, + action, + }) + } + + function setValue(key: string, value: string | number | boolean | string[]) { + setStore("values", { ...store.values, [key]: value }) + } + + function toggleMultiSelect(key: string, option: string) { + const current = (store.values[key] as string[]) ?? [] + const index = current.indexOf(option) + if (index === -1) { + setValue(key, [...current, option]) + } else { + setValue(key, current.filter((v) => v !== option)) + } + } + + useKeyboard((evt) => { + if (dialog.stack.length > 0) return + + // Submit shortcut - check BEFORE field check so it always works + if (evt.name === "s" && evt.ctrl) { + evt.preventDefault() + submit() + return + } + + const field = currentField() + if (!field) return + + // When editing a text field + if (store.editing) { + if (evt.name === "escape") { + evt.preventDefault() + setStore("editing", false) + return + } + if (evt.name === "return") { + evt.preventDefault() + const text = textarea?.plainText?.trim() ?? "" + setValue(field.key, field.type === "number" ? parseFloat(text) || 0 : text) + setStore("editing", false) + // Move to next field + if (store.selectedField < fields().length - 1) { + setStore("selectedField", store.selectedField + 1) + } + return + } + return + } + + // Navigation + if (evt.name === "up" || evt.name === "k") { + evt.preventDefault() + if (field.type === "enum" || field.type === "multiselect") { + const opts = field.options ?? [] + setStore("selectedOption", (store.selectedOption - 1 + opts.length) % opts.length) + } else { + setStore("selectedField", Math.max(0, store.selectedField - 1)) + setStore("selectedOption", 0) + } + } + + if (evt.name === "down" || evt.name === "j") { + evt.preventDefault() + if (field.type === "enum" || field.type === "multiselect") { + const opts = field.options ?? [] + setStore("selectedOption", (store.selectedOption + 1) % opts.length) + } else { + setStore("selectedField", Math.min(fields().length - 1, store.selectedField + 1)) + setStore("selectedOption", 0) + } + } + + if (evt.name === "tab") { + evt.preventDefault() + setStore("selectedField", (store.selectedField + 1) % fields().length) + setStore("selectedOption", 0) + } + + // Submit with Ctrl+Enter (check this BEFORE plain return) + if (evt.name === "return" && evt.ctrl) { + evt.preventDefault() + submit() + return + } + + // Actions (plain return without ctrl) + if (evt.name === "return") { + evt.preventDefault() + if (field.type === "string" || field.type === "number") { + setStore("editing", true) + } else if (field.type === "boolean") { + setValue(field.key, !store.values[field.key]) + } else if (field.type === "enum") { + const opt = field.options?.[store.selectedOption] + if (opt) { + setValue(field.key, opt.value) + // Move to next field + if (store.selectedField < fields().length - 1) { + setStore("selectedField", store.selectedField + 1) + setStore("selectedOption", 0) + } + } + } else if (field.type === "multiselect") { + const opt = field.options?.[store.selectedOption] + if (opt) { + toggleMultiSelect(field.key, opt.value) + } + } + } + + // Cancel/Decline + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + } + }) + + return ( + + + {/* Header */} + + MCP Elicitation + from {props.request.serverName} + + + {/* Message */} + + {props.request.message} + + + {/* Form Fields */} + 0}> + + + {(field, index) => { + const isActive = () => index() === store.selectedField + const value = () => store.values[field.key] + + return ( + + {/* Field label */} + + + {field.title} + {field.required ? " *" : ""} + + + - {field.description} + + + + {/* String/Number input */} + + + +