From 1a2e40111c6bf6f66a38ac2d04a2cd01216aa3ee Mon Sep 17 00:00:00 2001 From: Aliaksei Venski Date: Tue, 25 Aug 2026 12:10:49 +0200 Subject: [PATCH] fix(ui): handle nullable JSON-Schema type arrays in tool call arguments resolveSchemaProperty now collapses a type array (e.g. ["integer", "null"], the shape Pydantic's model_json_schema() emits for Optional[int]) to its non-null member before any caller branches on it - it previously only handled the anyOf/oneOf nullable encoding. buildDefaultValue no longer synthesizes 0/false for an untouched nullable numeric/boolean field, since that value looks user-provided and gets sent to the tool even when the field was never touched. initialArgumentValues now passes the raw (unresolved) prop into buildDefaultValue so it can still see the original array type and know the field is nullable. Found and fixed while rebasing this branch onto the current litellm_internal_staging, verified via the existing and newly-added tests in toolCallArguments/ToolTestPanel and MCPToolArgumentsForm. --- .../_components/toolCallArguments.ts | 21 ++++++++++++++++--- .../mcp_tools/MCPToolArgumentsForm.tsx | 7 +++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/toolCallArguments.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/toolCallArguments.ts index bdf0a52d02d..4e2833b7103 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/toolCallArguments.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/toolCallArguments.ts @@ -16,6 +16,12 @@ const isPlainObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); export const resolveSchemaProperty = (prop: InputSchemaProperty): InputSchemaProperty => { + // JSON Schema represents a nullable/optional field as a type array (e.g. ["integer", "null"]) - + // the shape Pydantic's model_json_schema() emits for Optional[int]. Collapse to the single + // non-null type before any caller branches on it. + if (Array.isArray(prop.type)) { + return { ...prop, type: prop.type.find((t) => t !== "null") ?? prop.type[0] }; + } if (prop.type !== undefined) return prop; const members = (prop.anyOf ?? prop.oneOf ?? []).filter((member) => member.type !== "null"); if (members.length !== 1 || members[0].type === undefined) return prop; @@ -185,12 +191,17 @@ function buildDefaultValue(declared: InputSchemaProperty | undefined, overrideDe if (prop.type === "array") return buildArrayDefault(prop, effectiveDefault); if (effectiveDefault !== undefined) return effectiveDefault; + // A nullable numeric/boolean field (an array "type" on the ORIGINAL declaration) with no + // explicit default starts empty rather than a synthetic 0/false: unlike a string's natural "" + // default, that value looks user-provided, passes the non-empty submission filter in + // buildToolCallArguments, and gets sent to the tool even when the field was never touched. + const isNullable = Array.isArray(declared.type); switch (prop.type) { case "integer": case "number": - return 0; + return isNullable ? undefined : 0; case "boolean": - return false; + return isNullable ? undefined : false; default: return ""; } @@ -199,7 +210,11 @@ function buildDefaultValue(declared: InputSchemaProperty | undefined, overrideDe export const initialArgumentValues = (fields: readonly ToolArgumentField[]): unknown[] => fields.map(({ prop }) => { const resolved = resolveSchemaProperty(prop); - const defaultValue = buildDefaultValue(resolved); + // Pass the RAW prop, not `resolved` - buildDefaultValue resolves it again internally, and + // needs the original `type` (possibly still an array) to know the field is nullable at all. + // Passing the pre-resolved (already-scalar) type here would make Array.isArray(declared.type) + // always false, silently defeating the nullable-omits-default behavior above. + const defaultValue = buildDefaultValue(prop); if (isJsonField(resolved)) { return isBlank(defaultValue) ? "" : JSON.stringify(defaultValue, null, 2); } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx index 0738c05ffc9..48fbcca0b88 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx @@ -129,7 +129,8 @@ function convertFormValues( } }); - const isNestedParams = resolveSchemaType(schema.properties?.params?.type) === "object" && schema.properties?.params?.properties; + const isNestedParams = + resolveSchemaType(schema.properties?.params?.type) === "object" && schema.properties?.params?.properties; return isNestedParams ? { params: convertedValues } : convertedValues; } @@ -326,7 +327,9 @@ const MCPToolArgumentsForm = forwardRef );