diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.test.tsx new file mode 100644 index 0000000000..883e30e492 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.test.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ToolTestPanel } from "./ToolTestPanel"; +import { InputSchema, MCPTool } from "./types"; + +vi.mock("../molecules/notifications_manager", () => ({ + default: { + success: vi.fn(), + fromBackend: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }, +})); + +const buildTool = (schema: InputSchema | string): MCPTool => ({ + name: "demo-tool", + description: "demo", + inputSchema: schema, + mcp_info: { server_name: "demo-server" }, +}); + +const renderPanel = (schema: InputSchema | string) => + render( + , + ); + +describe("ToolTestPanel defaults", () => { + it("pre-populates primitive, array, and nested object inputs from schema", () => { + const schema: InputSchema = { + type: "object", + properties: { + message: { type: "string", description: "Prompt text" }, + attempts: { type: "integer" }, + ratio: { type: "number", default: 0.4 }, + active: { type: "boolean", default: true }, + keywords: { + type: "array", + items: { type: "string" }, + description: "keywords array", + }, + payload: { + type: "object", + properties: { + user: { + type: "object", + properties: { + id: { type: "string", description: "user id" }, + tags: { + type: "array", + items: { type: "string" }, + default: [], + description: "optional tags", + }, + }, + required: ["id"], + }, + context: { + type: "object", + properties: { + topic: { type: "string" }, + extra: { + type: "object", + properties: { + note: { type: "string" }, + score: { type: "number" }, + }, + }, + }, + required: ["topic"], + }, + }, + required: ["user", "context"], + }, + }, + }; + + renderPanel(schema); + + expect(screen.getByLabelText("message")).toHaveValue(""); + expect(screen.getByLabelText("attempts")).toHaveValue(0); + expect(screen.getByLabelText("ratio")).toHaveValue(0.4); + expect(screen.getByDisplayValue("True")).toBeInTheDocument(); + + const keywordsTextarea = screen.getByTestId("textarea-keywords"); + expect(JSON.parse(keywordsTextarea.value)).toEqual([""]); + + const payloadTextarea = screen.getByTestId("textarea-payload"); + expect(JSON.parse(payloadTextarea.value)).toEqual({ + user: { + id: "", + tags: [""], + }, + context: { + topic: "", + extra: { + note: "", + score: 0, + }, + }, + }); + }); + + it("uses nested params schema when present", () => { + const schema: InputSchema = { + type: "object", + properties: { + params: { + type: "object", + properties: { + query: { type: "string" }, + filters: { + type: "object", + properties: { + tag: { type: "string" }, + metadata: { + type: "object", + properties: { + source: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }; + + renderPanel(schema); + + expect(screen.getByLabelText("query")).toBeInTheDocument(); + const filtersTextarea = screen.getByTestId("textarea-filters"); + expect(JSON.parse(filtersTextarea.value)).toEqual({ + tag: "", + metadata: { source: "" }, + }); + }); + + it("falls back to a plain input when schema is missing", () => { + renderPanel("tool_input_schema"); + + expect(screen.getByPlaceholderText("Enter input for this tool")).toBeInTheDocument(); + expect(screen.queryByText("No parameters required")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx index e0edb506fc..abd955f329 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx @@ -1,10 +1,105 @@ import React from "react"; import { Button, TextInput } from "@tremor/react"; -import { MCPTool, InputSchema } from "./types"; +import { MCPTool, InputSchema, InputSchemaProperty } from "./types"; import { Form, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import NotificationsManager from "../molecules/notifications_manager"; +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +function buildArrayItems(items?: InputSchemaProperty | InputSchemaProperty[]): any[] { + if (!items) { + return []; + } + + if (Array.isArray(items)) { + return items + .map((item) => buildDefaultValue(item)) + .filter((value) => value !== undefined); + } + + const itemDefault = buildDefaultValue(items); + if (itemDefault === undefined) { + return []; + } + + return [itemDefault]; +} + +function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): any { + if (!prop) { + return undefined; + } + + const effectiveDefault = overrideDefault !== undefined ? overrideDefault : prop.default; + + if (prop.type === "object") { + const base = isPlainObject(effectiveDefault) ? { ...effectiveDefault } : {}; + + if (prop.properties) { + Object.entries(prop.properties).forEach(([childKey, childProp]) => { + base[childKey] = buildDefaultValue(childProp, base[childKey]); + }); + } + + return base; + } + + if (prop.type === "array") { + if (Array.isArray(effectiveDefault)) { + const itemSchema = prop.items; + if (!itemSchema) { + return effectiveDefault; + } + + if (effectiveDefault.length === 0) { + const sample = buildArrayItems(itemSchema); + return sample.length ? sample : effectiveDefault; + } + + if (Array.isArray(itemSchema)) { + return effectiveDefault.map((value, index) => { + const schema = itemSchema[index] ?? itemSchema[itemSchema.length - 1]; + return buildDefaultValue(schema, value); + }); + } + + return effectiveDefault.map((value) => buildDefaultValue(itemSchema, value)); + } + + if (effectiveDefault !== undefined) { + return effectiveDefault; + } + + return buildArrayItems(prop.items); + } + + if (effectiveDefault !== undefined) { + return effectiveDefault; + } + + switch (prop.type) { + case "integer": + case "number": + return 0; + case "boolean": + return false; + case "string": + default: + return ""; + } +} + +const getInitialValueForField = (prop: InputSchemaProperty): any => { + const defaultValue = buildDefaultValue(prop); + if (prop.type === "object" || prop.type === "array") { + const fallback = prop.type === "array" ? [] : {}; + return JSON.stringify(defaultValue ?? fallback, null, 2); + } + return defaultValue; +}; + export function ToolTestPanel({ tool, onSubmit, @@ -61,6 +156,21 @@ export function ToolTestPanel({ return schema; }, [schema]); + React.useEffect(() => { + form.resetFields(); + + if (!actualSchema.properties) { + return; + } + + const initialValues: Record = {}; + Object.entries(actualSchema.properties).forEach(([key, prop]) => { + initialValues[key] = getInitialValueForField(prop); + }); + + form.setFieldsValue(initialValues); + }, [form, actualSchema, tool]); + const handleSubmit = (values: Record) => { const start = Date.now(); setStartTime(start); @@ -78,8 +188,32 @@ export function ToolTestPanel({ convertedValues[key] = value === "true" || value === true; break; case "number": - convertedValues[key] = Number(value); + case "integer": { + const numericValue = Number(value); + convertedValues[key] = Number.isNaN(numericValue) + ? value + : prop.type === "integer" + ? Math.trunc(numericValue) + : numericValue; break; + } + case "object": + case "array": { + 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)) { + convertedValues[key] = parsed; + } else { + convertedValues[key] = value; + } + } catch (err) { + convertedValues[key] = value; + } + break; + } case "string": convertedValues[key] = String(value); break; @@ -249,69 +383,136 @@ export function ToolTestPanel({ ) : (
- {Object.entries(actualSchema.properties).map(([key, prop]) => ( - - {key} {actualSchema.required?.includes(key) && *} - {prop.description && ( - - - - )} - - } - name={key} - rules={[ - { - required: actualSchema.required?.includes(key), + {Object.entries(actualSchema.properties).map(([key, prop]) => { + const initialValue = getInitialValueForField(prop); + const fieldKey = `${tool.name}-${key}`; + return ( + + {key} {actualSchema.required?.includes(key) && *} + {prop.description && ( + + + + )} + + } + name={key} + initialValue={initialValue} + rules={[ + { + required: actualSchema.required?.includes(key), message: `Please enter ${key}`, }, + ...(prop.type === "object" || prop.type === "array" + ? [ + { + validator: (_rule: any, value: any) => { + if ( + (value === undefined || value === null || value === "") && + !actualSchema.required?.includes(key) + ) { + return Promise.resolve(); + } + + 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)) { + return Promise.resolve(); + } + + return Promise.reject( + new Error( + prop.type === "object" + ? "Please enter a JSON object" + : "Please enter a JSON array", + ), + ); + } catch (error) { + return Promise.reject(new Error("Invalid JSON")); + } + }, + }, + ] + : []), ]} - className="mb-3" - > - {prop.type === "string" && prop.enum && ( - - )} + className="mb-3" + > + {prop.type === "string" && prop.enum && ( + + )} - {prop.type === "string" && !prop.enum && ( - - )} + {prop.type === "string" && !prop.enum && ( + + )} - {prop.type === "number" && ( - - )} + {(prop.type === "number" || prop.type === "integer") && ( + + )} - {prop.type === "boolean" && ( - - )} - - ))} + {prop.type === "boolean" && ( + + )} + + {(prop.type === "object" || prop.type === "array") && ( +
+