diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx index 26e66c8338c..9ac67563011 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx @@ -158,6 +158,54 @@ describe("ToolTestPanel defaults", () => { expect(callButton.closest("form")).not.toBeNull(); expect(callButton).toHaveAttribute("type", "button"); }); + + it("renders inputs for nullable (type-array) schema properties instead of leaving them blank", () => { + // JSON Schema represents an optional/nullable field as an array type, e.g. ["string", "null"] - + // the shape Pydantic's model_json_schema() emits for Optional[str]. A real Azure DevOps MCP tool + // schema (testplan_test_suite_write) declares its optional fields exactly this way. + const schema: InputSchema = { + type: "object", + properties: { + name: { type: ["string", "null"], description: "Optional name" }, + parentSuiteId: { type: ["integer", "null"], description: "Optional parent suite id" }, + active: { type: ["boolean", "null"], description: "Optional flag" }, + }, + }; + + renderPanel(schema); + + expect(screen.getByLabelText("name")).toHaveValue(""); + expect(screen.getByLabelText("parentSuiteId")).toHaveValue(0); + expect(screen.getByLabelText("active")).toBeInTheDocument(); + }); + + it("coerces a nullable integer field to a number on submit, not a string", async () => { + const schema: InputSchema = { + type: "object", + properties: { + parentSuiteId: { type: ["integer", "null"], description: "Optional parent suite id" }, + }, + }; + const onSubmit = vi.fn(); + + render( + , + ); + + const input = screen.getByLabelText("parentSuiteId"); + await userEvent.clear(input); + await userEvent.type(input, "42"); + await userEvent.click(screen.getByRole("button", { name: "Call Tool" })); + + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ parentSuiteId: 42 })); + }); }); describe("ToolTestPanel argument payload", () => { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.test.tsx index d6e614da5eb..60a808c4496 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.test.tsx @@ -178,3 +178,39 @@ describe("MCPToolArgumentsForm", () => { await expect(submit(ref)).resolves.toEqual({}); }); }); + +describe("MCPToolArgumentsForm nullable schema types", () => { + it("renders inputs for nullable (type-array) schema properties instead of a plain text fallback", () => { + // JSON Schema represents an optional/nullable field as an array type, e.g. ["integer", "null"] - + // the shape Pydantic's model_json_schema() emits for Optional[int]. Unlike ToolTestPanel, this + // component has a final "else" branch that falls back to a plain text Input, so the bug here + // is a wrong widget (text instead of number), not a missing one. + renderForm({ + type: "object", + properties: { + parentSuiteId: { type: ["integer", "null"], description: "Optional parent suite id" }, + }, + required: [], + }); + + const input = screen.getByLabelText("parentSuiteId"); + expect(input).toHaveValue(0); + }); + + it("coerces a nullable integer field to a number in getSubmitValues, not a string", async () => { + const user = userEvent.setup(); + const ref = renderForm({ + type: "object", + properties: { + parentSuiteId: { type: ["integer", "null"], description: "Optional parent suite id" }, + }, + required: [], + }); + + const input = screen.getByLabelText("parentSuiteId"); + await user.clear(input); + await user.type(input, "42"); + + await expect(submit(ref)).resolves.toEqual(expect.objectContaining({ parentSuiteId: 42 })); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx index ca8c5697e6e..7f06cfbdd4e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx @@ -20,14 +20,14 @@ const BOOLEAN_ITEMS = [ const isBlank = (value: unknown): boolean => value === undefined || value === null || value === ""; -const jsonErrorFor = (prop: InputSchemaProperty, value: unknown): string | null => { +const jsonErrorFor = (effectiveType: string | undefined, value: unknown): string | null => { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; const isValidObject = - prop.type === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); - const isValidArray = prop.type === "array" && Array.isArray(parsed); + effectiveType === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); + const isValidArray = effectiveType === "array" && Array.isArray(parsed); if (isValidObject || isValidArray) return null; - return prop.type === "object" ? "Please enter a JSON object" : "Please enter a JSON array"; + return effectiveType === "object" ? "Please enter a JSON object" : "Please enter a JSON array"; } catch { return "Invalid JSON"; } @@ -46,9 +46,10 @@ const collectErrors = ( if (actualSchema.required?.includes(key) && blank) { return [[key, { type: "required", message: requiredMessages[key] ?? `Please enter ${key}` }]]; } - if (prop.type !== "object" && prop.type !== "array") return []; + const effectiveType = resolveSchemaType(prop.type); + if (effectiveType !== "object" && effectiveType !== "array") return []; if (blank) return []; - const message = jsonErrorFor(prop, value); + const message = jsonErrorFor(effectiveType, value); return message === null ? [] : [[key, { type: "validate", message }]]; }); return Object.fromEntries(entries); @@ -76,6 +77,17 @@ const labelFor = (key: string, prop: InputSchemaProperty, required: boolean): Re const isPlainObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +// JSON Schema allows "type" to be an array (e.g. ["string", "null"]) for a nullable field, the +// shape Pydantic's model_json_schema() emits for Optional[str] / str | None. Every branch below +// that decides a widget or a value conversion from a property's type needs the single effective +// (non-null) type, not the raw field verbatim. +function resolveSchemaType(type: InputSchemaProperty["type"] | undefined): string | undefined { + if (Array.isArray(type)) { + return type.find((t) => t !== "null") ?? type[0]; + } + return type; +} + function buildArrayItems(items?: InputSchemaProperty | InputSchemaProperty[]): any[] { if (!items) return []; if (Array.isArray(items)) { @@ -88,8 +100,9 @@ function buildArrayItems(items?: InputSchemaProperty | InputSchemaProperty[]): a function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): any { if (!prop) return undefined; const effectiveDefault = overrideDefault !== undefined ? overrideDefault : prop.default; + const effectiveType = resolveSchemaType(prop.type); - if (prop.type === "object") { + if (effectiveType === "object") { const base = isPlainObject(effectiveDefault) ? { ...effectiveDefault } : {}; if (prop.properties) { Object.entries(prop.properties).forEach(([childKey, childProp]) => { @@ -99,7 +112,7 @@ function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): a return base; } - if (prop.type === "array") { + if (effectiveType === "array") { if (Array.isArray(effectiveDefault)) { const itemSchema = prop.items; if (!itemSchema) return effectiveDefault; @@ -120,7 +133,7 @@ function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): a } if (effectiveDefault !== undefined) return effectiveDefault; - switch (prop.type) { + switch (effectiveType) { case "integer": case "number": return 0; @@ -134,8 +147,9 @@ function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): a const getInitialValueForField = (prop: InputSchemaProperty): any => { const defaultValue = buildDefaultValue(prop); - if (prop.type === "object" || prop.type === "array") { - const fallback = prop.type === "array" ? [] : {}; + const effectiveType = resolveSchemaType(prop.type); + if (effectiveType === "object" || effectiveType === "array") { + const fallback = effectiveType === "array" ? [] : {}; return JSON.stringify(defaultValue ?? fallback, null, 2); } return defaultValue; @@ -152,7 +166,8 @@ function convertFormValues( Object.entries(values).forEach(([key, value]) => { const prop = schemaToUse.properties?.[key]; if (prop && value !== null && value !== undefined && value !== "") { - switch (prop.type) { + const effectiveType = resolveSchemaType(prop.type); + switch (effectiveType) { case "boolean": convertedValues[key] = value === "true" || value === true; break; @@ -161,7 +176,7 @@ function convertFormValues( const numericValue = Number(value); convertedValues[key] = Number.isNaN(numericValue) ? value - : prop.type === "integer" + : effectiveType === "integer" ? Math.trunc(numericValue) : numericValue; break; @@ -171,9 +186,9 @@ function convertFormValues( try { const parsed = typeof value === "string" ? JSON.parse(value) : value; const isValidObject = - prop.type === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); - const isValidArray = prop.type === "array" && Array.isArray(parsed); - if ((prop.type === "object" && isValidObject) || (prop.type === "array" && isValidArray)) { + effectiveType === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); + const isValidArray = effectiveType === "array" && Array.isArray(parsed); + if ((effectiveType === "object" && isValidObject) || (effectiveType === "array" && isValidArray)) { convertedValues[key] = parsed; } else { convertedValues[key] = value; @@ -194,7 +209,7 @@ function convertFormValues( } }); - const isNestedParams = 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; } @@ -227,7 +242,7 @@ const MCPToolArgumentsForm = forwardRef { - if (schema.properties?.params?.type === "object" && schema.properties.params.properties) { + if (resolveSchemaType(schema.properties?.params?.type) === "object" && schema.properties?.params?.properties) { return { type: "object", properties: schema.properties.params.properties, @@ -320,6 +335,7 @@ const MCPToolArgumentsForm = forwardRef {Object.entries(actualSchema.properties).map(([key, prop]) => { const required = actualSchema.required?.includes(key) ?? false; + const effectiveType = resolveSchemaType(prop.type); return ( {(field) => { - if (prop.type === "string" && prop.enum) { + if (effectiveType === "string" && prop.enum) { return ( ); } - if (prop.type === "number" || prop.type === "integer") { + if (effectiveType === "number" || effectiveType === "integer") { return ( ); } - if (prop.type === "object" || prop.type === "array") { + if (effectiveType === "object" || effectiveType === "array") { return (