| 1 | import { useEffect, useId, useRef } from "react"; |
| 2 | import { useI18n } from "../lib/i18n"; |
| 3 | import { |
| 4 | structuredFieldIssue, |
| 5 | type StructuredField, |
| 6 | type StructuredFieldIssue, |
| 7 | type StructuredFieldValue, |
| 8 | } from "../lib/structuredFormSchema"; |
| 9 | |
| 10 | export { |
| 11 | coerceStructuredValues, |
| 12 | initialStructuredValues, |
| 13 | missingStructuredRequired, |
| 14 | normalizeStructuredSchema, |
| 15 | parseStructuredSchema, |
| 16 | } from "../lib/structuredFormSchema"; |
| 17 | export type { StructuredField, StructuredFieldIssue, StructuredFieldValue } from "../lib/structuredFormSchema"; |
| 18 | |
| 19 | function inputType(field: StructuredField): "text" | "email" | "url" | "date" | "number" { |
| 20 | if (field.kind === "number" || field.kind === "integer") return "number"; |
| 21 | if (field.format === "email") return "email"; |
| 22 | if (field.format === "uri") return "url"; |
| 23 | if (field.format === "date") return "date"; |
| 24 | // RFC 3339 date-times can include a timezone, which datetime-local drops. |
| 25 | return "text"; |
| 26 | } |
| 27 | |
| 28 | // StructuredForm renders the complete MCP flat-schema subset. It owns no |
| 29 | // protocol state: the caller decides when validation becomes visible and when |
| 30 | // values are submitted. |
| 31 | export function StructuredForm({ |
| 32 | fields, |
| 33 | values, |
| 34 | onChange, |
| 35 | disabled, |
| 36 | showErrors = false, |
| 37 | focusInvalidNonce = 0, |
| 38 | }: { |
| 39 | fields: StructuredField[]; |
| 40 | values: Record<string, StructuredFieldValue>; |
| 41 | onChange: (next: Record<string, StructuredFieldValue>) => void; |
| 42 | disabled?: boolean; |
| 43 | showErrors?: boolean; |
| 44 | focusInvalidNonce?: number; |
| 45 | }) { |
| 46 | const { t } = useI18n(); |
| 47 | const formID = useId().replace(/:/g, ""); |
| 48 | const controls = useRef(new Map<string, HTMLElement>()); |
| 49 | |
| 50 | useEffect(() => { |
| 51 | const first = fields.find((field) => field.kind !== "unsupported"); |
| 52 | if (first) controls.current.get(first.key)?.focus(); |
| 53 | }, [fields]); |
| 54 | |
| 55 | useEffect(() => { |
| 56 | if (!focusInvalidNonce) return; |
| 57 | const firstInvalid = fields.find((field) => structuredFieldIssue(field, values[field.key])); |
| 58 | if (firstInvalid) controls.current.get(firstInvalid.key)?.focus(); |
| 59 | }, [fields, focusInvalidNonce, values]); |
| 60 | |
| 61 | const set = (key: string, value: StructuredFieldValue | undefined) => { |
| 62 | const next = { ...values }; |
| 63 | if (value === undefined) delete next[key]; |
| 64 | else next[key] = value; |
| 65 | onChange(next); |
| 66 | }; |
| 67 | |
| 68 | const messageFor = (field: StructuredField, issue: StructuredFieldIssue): string => { |
| 69 | if (issue === "required") return t("mcp.interaction.fieldRequired"); |
| 70 | if (issue === "tooShort") return t("mcp.interaction.fieldTooShort", { min: field.minLength ?? 0 }); |
| 71 | if (issue === "tooLong") return t("mcp.interaction.fieldTooLong", { max: field.maxLength ?? 0 }); |
| 72 | if (issue === "belowMinimum") return t("mcp.interaction.fieldBelowMinimum", { min: field.minimum ?? 0 }); |
| 73 | if (issue === "aboveMaximum") return t("mcp.interaction.fieldAboveMaximum", { max: field.maximum ?? 0 }); |
| 74 | if (issue === "tooFewItems") return t("mcp.interaction.fieldTooFewItems", { min: field.minItems ?? 0 }); |
| 75 | if (issue === "tooManyItems") return t("mcp.interaction.fieldTooManyItems", { max: field.maxItems ?? 0 }); |
| 76 | if (issue === "unsupported") return t("mcp.interaction.fieldUnsupported"); |
| 77 | return t("mcp.interaction.fieldInvalid"); |
| 78 | }; |
| 79 | |
| 80 | return ( |
| 81 | <div className="structured-form"> |
| 82 | {fields.map((field, index) => { |
| 83 | const raw = values[field.key]; |
| 84 | const fieldID = `${formID}-field-${index}`; |
| 85 | const hintID = field.description ? `${fieldID}-hint` : undefined; |
| 86 | const issue = structuredFieldIssue(field, raw); |
| 87 | const errorID = showErrors && issue ? `${fieldID}-error` : undefined; |
| 88 | const describedBy = [hintID, errorID].filter(Boolean).join(" ") || undefined; |
| 89 | const setControl = (node: HTMLElement | null) => { |
| 90 | if (node) controls.current.set(field.key, node); |
| 91 | else controls.current.delete(field.key); |
| 92 | }; |
| 93 | const label = ( |
| 94 | <> |
| 95 | {field.label} |
| 96 | {field.required ? <span className="structured-form-required" aria-hidden="true"> *</span> : null} |
| 97 | </> |
| 98 | ); |
| 99 | const help = ( |
| 100 | <> |
| 101 | {field.description ? <span id={hintID} className="structured-form-hint">{field.description}</span> : null} |
| 102 | {showErrors && issue ? <span id={errorID} className="structured-form-error" role="alert">{messageFor(field, issue)}</span> : null} |
| 103 | </> |
| 104 | ); |
| 105 | |
| 106 | if (field.kind === "unsupported") { |
| 107 | return ( |
| 108 | <div key={field.key} className="structured-form-field structured-form-field--wide"> |
| 109 | <span className="structured-form-label">{label}</span> |
| 110 | <span className="structured-form-unsupported" role="alert">{t("mcp.interaction.fieldUnsupported")}</span> |
| 111 | </div> |
| 112 | ); |
| 113 | } |
| 114 | |
| 115 | if (field.kind === "boolean") { |
| 116 | return ( |
| 117 | <div key={field.key} className="structured-form-field structured-form-field--wide"> |
| 118 | <label className="structured-form-checkbox-row" htmlFor={fieldID}> |
| 119 | <input |
| 120 | ref={setControl} |
| 121 | id={fieldID} |
| 122 | type="checkbox" |
| 123 | className="structured-form-checkbox" |
| 124 | checked={raw === true} |
| 125 | disabled={disabled} |
| 126 | aria-required={field.required || undefined} |
| 127 | aria-invalid={showErrors && issue ? true : undefined} |
| 128 | aria-describedby={describedBy} |
| 129 | onChange={(event) => set(field.key, event.target.checked)} |
| 130 | /> |
| 131 | <span className="structured-form-label">{label}</span> |
| 132 | </label> |
| 133 | {help} |
| 134 | </div> |
| 135 | ); |
| 136 | } |
| 137 | |
| 138 | if (field.kind === "multi-enum" && field.options) { |
| 139 | const selected = Array.isArray(raw) ? raw : []; |
| 140 | return ( |
| 141 | <fieldset |
| 142 | key={field.key} |
| 143 | className="structured-form-field structured-form-field--wide structured-form-options" |
| 144 | aria-required={field.required || undefined} |
| 145 | aria-invalid={showErrors && issue ? true : undefined} |
| 146 | aria-describedby={describedBy} |
| 147 | > |
| 148 | <legend className="structured-form-label">{label}</legend> |
| 149 | {field.options.map((option, optionIndex) => ( |
| 150 | <label key={option.value} className="structured-form-option"> |
| 151 | <input |
| 152 | ref={optionIndex === 0 ? setControl : undefined} |
| 153 | type="checkbox" |
| 154 | checked={selected.includes(option.value)} |
| 155 | disabled={disabled} |
| 156 | onChange={(event) => { |
| 157 | const next = event.target.checked |
| 158 | ? [...selected, option.value] |
| 159 | : selected.filter((value) => value !== option.value); |
| 160 | set(field.key, next); |
| 161 | }} |
| 162 | /> |
| 163 | <span>{option.label}</span> |
| 164 | </label> |
| 165 | ))} |
| 166 | {help} |
| 167 | </fieldset> |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | const stringRaw = raw === undefined || Array.isArray(raw) ? "" : String(raw); |
| 172 | return ( |
| 173 | <div key={field.key} className="structured-form-field"> |
| 174 | <label className="structured-form-label" htmlFor={fieldID}>{label}</label> |
| 175 | {field.kind === "enum" && field.options ? ( |
| 176 | <select |
| 177 | ref={setControl} |
| 178 | id={fieldID} |
| 179 | className="structured-form-control" |
| 180 | value={stringRaw} |
| 181 | disabled={disabled} |
| 182 | required={field.required} |
| 183 | aria-required={field.required || undefined} |
| 184 | aria-invalid={showErrors && issue ? true : undefined} |
| 185 | aria-describedby={describedBy} |
| 186 | onChange={(event) => set(field.key, event.target.value || undefined)} |
| 187 | > |
| 188 | <option value="">{t("mcp.interaction.chooseOption")}</option> |
| 189 | {field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} |
| 190 | </select> |
| 191 | ) : ( |
| 192 | <input |
| 193 | ref={setControl} |
| 194 | id={fieldID} |
| 195 | type={inputType(field)} |
| 196 | className="structured-form-control" |
| 197 | value={stringRaw} |
| 198 | disabled={disabled} |
| 199 | required={field.required} |
| 200 | aria-required={field.required || undefined} |
| 201 | aria-invalid={showErrors && issue ? true : undefined} |
| 202 | aria-describedby={describedBy} |
| 203 | min={field.minimum} |
| 204 | max={field.maximum} |
| 205 | step={field.kind === "integer" ? 1 : field.kind === "number" ? "any" : undefined} |
| 206 | minLength={field.minLength} |
| 207 | maxLength={field.maxLength} |
| 208 | inputMode={field.kind === "integer" ? "numeric" : field.kind === "number" ? "decimal" : undefined} |
| 209 | placeholder={field.format === "date-time" ? "2026-08-29T12:00:00Z" : undefined} |
| 210 | onChange={(event) => set(field.key, event.target.value || undefined)} |
| 211 | /> |
| 212 | )} |
| 213 | {help} |
| 214 | </div> |
| 215 | ); |
| 216 | })} |
| 217 | </div> |
| 218 | ); |
| 219 | } |
| 220 |