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 (