diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 90249ab1c..84dd41872 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, + useMemo, useRef, forwardRef, useImperativeHandle, @@ -10,7 +11,11 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import JsonEditor from "./JsonEditor"; import { updateValueAtPath } from "@/utils/jsonUtils"; -import { generateDefaultValue } from "@/utils/schemaUtils"; +import { + generateDefaultValue, + mergeAllOf, + resolveRef, +} from "@/utils/schemaUtils"; import type { JsonValue, JsonSchemaType, @@ -55,6 +60,44 @@ const isSimpleObject = (schema: JsonSchemaType): boolean => { return false; }; +// A oneOf whose members are full schemas is a variant union rendered with a +// selector. oneOf members carrying const are titled enum options and keep +// their existing select rendering, as do schemas that already render on +// their own (a type other than a property-less object). +const getVariantOptions = (schema: JsonSchemaType): JsonSchemaType[] | null => { + if (!schema.oneOf || schema.oneOf.length === 0) return null; + if (schema.oneOf.some((opt) => "const" in opt)) return null; + if (schema.type && !(schema.type === "object" && !schema.properties)) { + return null; + } + return schema.oneOf as JsonSchemaType[]; +}; + +// Picks the variant whose properties best match the keys already present in +// the value, so an existing value does not silently render the first variant +const inferVariantIndex = ( + variants: JsonSchemaType[], + value: JsonValue, +): number | undefined => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const keys = Object.keys(value); + if (keys.length === 0) return undefined; + + let bestIdx: number | undefined; + let bestScore = 0; + variants.forEach((variant, idx) => { + const props = variant.properties ?? {}; + const score = keys.filter((key) => key in props).length; + if (score > bestScore) { + bestScore = score; + bestIdx = idx; + } + }); + return bestIdx; +}; + const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => { if ("default" in schema && schema.default !== undefined) { return schema.default; @@ -81,14 +124,20 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => { const DynamicJsonForm = forwardRef( ({ schema, value, onChange, maxDepth = 3 }, ref) => { + // Merge allOf branches up front so composed schemas render like flat ones + const resolvedSchema = useMemo(() => mergeAllOf(schema), [schema]); + // Determine if we can render a form at the top level. // This is more permissive than isSimpleObject(): // - Objects with any properties are form-capable (individual complex fields may still fallback to JSON) // - Arrays with defined items are form-capable // - Primitive types are form-capable + // - oneOf variant unions are form-capable via the variant selector const canRenderTopLevelForm = (s: JsonSchemaType): boolean => { const primitiveTypes = ["string", "number", "integer", "boolean", "null"]; + if (getVariantOptions(s)) return true; + const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type; if (!hasType) return false; @@ -114,7 +163,7 @@ const DynamicJsonForm = forwardRef( return false; }; - const isOnlyJSON = !canRenderTopLevelForm(schema); + const isOnlyJSON = !canRenderTopLevelForm(resolvedSchema); const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); const [jsonError, setJsonError] = useState(); const [copiedJson, setCopiedJson] = useState(false); @@ -123,11 +172,14 @@ const DynamicJsonForm = forwardRef( // Store the raw JSON string to allow immediate feedback during typing // while deferring parsing until the user stops typing const [rawJsonValue, setRawJsonValue] = useState( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + JSON.stringify(value ?? generateDefaultValue(resolvedSchema), null, 2), ); const [numericInputDrafts, setNumericInputDrafts] = useState< Record >({}); + const [variantSelections, setVariantSelections] = useState< + Record + >({}); // Use a ref to manage debouncing timeouts to avoid parsing JSON // on every keystroke which would be inefficient and error-prone @@ -191,22 +243,26 @@ const DynamicJsonForm = forwardRef( // Reset to default for clearly invalid JSON (not just incomplete typing) const trimmed = jsonString?.trim(); if (trimmed && trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) { - onChange(generateDefaultValue(schema)); + onChange(generateDefaultValue(resolvedSchema)); } } }, 300); }, - [onChange, setJsonError, schema], + [onChange, setJsonError, resolvedSchema], ); // Update rawJsonValue when value prop changes useEffect(() => { if (!isJsonMode) { setRawJsonValue( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + JSON.stringify( + value ?? generateDefaultValue(resolvedSchema), + null, + 2, + ), ); } - }, [value, schema, isJsonMode]); + }, [value, resolvedSchema, isJsonMode]); const handleSwitchToFormMode = () => { if (isJsonMode) { @@ -223,7 +279,11 @@ const DynamicJsonForm = forwardRef( } else { // Update raw JSON value when switching to JSON mode setRawJsonValue( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + JSON.stringify( + value ?? generateDefaultValue(resolvedSchema), + null, + 2, + ), ); setIsJsonMode(true); } @@ -302,7 +362,81 @@ const DynamicJsonForm = forwardRef( depth: number = 0, parentSchema?: JsonSchemaType, propertyName?: string, + variantDepth: number = 0, ) => { + if (propSchema.allOf) { + propSchema = mergeAllOf(propSchema, resolvedSchema); + } + + const variants = getVariantOptions(propSchema); + if (variants) { + // variantDepth keeps directly nested selectors (a variant that is + // itself a oneOf) from sharing one state slot at the same path + const selectorKey = `${variantDepth}:${getPathKey(path)}`; + const selectedIdx = Math.min( + variantSelections[selectorKey] ?? + inferVariantIndex(variants, currentValue) ?? + 0, + variants.length - 1, + ); + const resolveVariant = (idx: number): JsonSchemaType => { + const variant = mergeAllOf( + resolveRef(variants[idx] ?? {}, resolvedSchema), + resolvedSchema, + ); + // properties without an explicit type is a common object shorthand + if (!variant.type && variant.properties) { + return { ...variant, type: "object" }; + } + return variant; + }; + return ( +
+ {propSchema.description && ( +

{propSchema.description}

+ )} + + {renderFormFields( + resolveVariant(selectedIdx), + currentValue, + path, + depth, + parentSchema, + propertyName, + variantDepth + 1, + )} +
+ ); + } + if ( depth >= maxDepth && (propSchema.type === "object" || propSchema.type === "array") @@ -347,8 +481,7 @@ const DynamicJsonForm = forwardRef( // Titled single-select using oneOf/anyOf with const/title pairs const titledOptions = ( (propSchema.oneOf ?? propSchema.anyOf) as - | (JsonSchemaType | JsonSchemaConst)[] - | undefined + (JsonSchemaType | JsonSchemaConst)[] | undefined )?.filter((opt): opt is JsonSchemaConst => "const" in opt); if (titledOptions && titledOptions.length > 0) { @@ -606,8 +739,7 @@ const DynamicJsonForm = forwardRef( const titledMulti = ( (itemSchema.anyOf ?? itemSchema.oneOf) as - | (JsonSchemaType | JsonSchemaConst)[] - | undefined + (JsonSchemaType | JsonSchemaConst)[] | undefined )?.filter((opt): opt is JsonSchemaConst => "const" in opt); if (titledMulti && titledMulti.length > 0) { @@ -766,8 +898,10 @@ const DynamicJsonForm = forwardRef( }; const shouldUseJsonMode = - schema.type === "object" && - (!schema.properties || Object.keys(schema.properties).length === 0); + resolvedSchema.type === "object" && + (!resolvedSchema.properties || + Object.keys(resolvedSchema.properties).length === 0) && + !getVariantOptions(resolvedSchema); useEffect(() => { if (shouldUseJsonMode && !isJsonMode) { @@ -825,11 +959,11 @@ const DynamicJsonForm = forwardRef( debouncedUpdateParent(newValue); }} error={jsonError} - placeholder={schema.description} + placeholder={resolvedSchema.description} /> ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, // render a simple representation of the JSON data - schema.type === "object" && + resolvedSchema.type === "object" && (typeof value !== "object" || value === null || Object.keys(value).length === 0) && @@ -848,7 +982,7 @@ const DynamicJsonForm = forwardRef(

) : ( - renderFormFields(schema, value) + renderFormFields(resolvedSchema, value) )} ); diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 15b85fd67..600502520 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -17,6 +17,7 @@ import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; import { generateDefaultValue, isPropertyRequired, + mergeAllOf, normalizeUnionType, resolveRef, } from "@/utils/schemaUtils"; @@ -225,23 +226,27 @@ const ToolsTab = ({ }; useEffect(() => { - const params = Object.entries( - selectedTool?.inputSchema.properties ?? [], - ).map(([key, value]) => { - // First resolve any $ref references - const resolvedValue = resolveRef( - value as JsonSchemaType, - selectedTool?.inputSchema as JsonSchemaType, - ); - return [ - key, - generateDefaultValue( - resolvedValue, + // Merge allOf branches so composed schemas expose their properties + const inputSchema = selectedTool + ? mergeAllOf(selectedTool.inputSchema as JsonSchemaType) + : undefined; + const params = Object.entries(inputSchema?.properties ?? []).map( + ([key, value]) => { + // First resolve any $ref references + const resolvedValue = resolveRef( + value as JsonSchemaType, + inputSchema as JsonSchemaType, + ); + return [ key, - selectedTool?.inputSchema as JsonSchemaType, - ), - ]; - }); + generateDefaultValue( + mergeAllOf(resolvedValue, inputSchema), + key, + inputSchema as JsonSchemaType, + ), + ]; + }, + ); setParams(Object.fromEntries(params)); const toolTaskSupport = serverSupportsTaskRequests ? getTaskSupport(selectedTool) @@ -274,6 +279,11 @@ const ToolsTab = ({ ? getTaskSupport(selectedTool) : "forbidden"; + // Merge allOf branches so composed schemas expose their properties + const inputSchema = selectedTool + ? mergeAllOf(selectedTool.inputSchema as JsonSchemaType) + : undefined; + return (
@@ -343,19 +353,24 @@ const ToolsTab = ({ : undefined } /> - {Object.entries(selectedTool.inputSchema.properties ?? []).map( + {Object.entries(inputSchema?.properties ?? []).map( ([key, value]) => { // First resolve any $ref references const resolvedValue = resolveRef( value as JsonSchemaType, - selectedTool.inputSchema as JsonSchemaType, + inputSchema as JsonSchemaType, + ); + const prop = normalizeUnionType( + mergeAllOf(resolvedValue, inputSchema), + ); + const required = isPropertyRequired( + key, + inputSchema as JsonSchemaType, ); - const prop = normalizeUnionType(resolvedValue); - const inputSchema = - selectedTool.inputSchema as JsonSchemaType; - const required = isPropertyRequired(key, inputSchema); + // Key by tool too, so form state never leaks between + // tools that share a property name return ( -
+