From fc32eb081a87e59a5273b48ffd64a446dfbc1ff9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 15:38:38 -0700 Subject: [PATCH] refactor(ui): move the MCP tool test form off antd The tool test panel drove its argument fields through an antd Form, so the call payload was whatever rc-field-form happened to have mounted. It now runs on react-hook-form with shadcn controls, and the payload itself lives in toolCallArguments.ts as a pure function of the schema fields plus the entered values. Fields bind by index rather than by name, because an MCP tool's JSON schema can name a property anything: a key containing a dot would be one flat key to antd but a nested path to react-hook-form. Binding to args.0, args.1 and zipping back to the real keys at submit time keeps the emitted arguments identical whatever the server calls its properties. Coercion, the blank filter, the required and JSON rules, and the params wrapper for nested-object schemas all keep their previous behaviour, and the neutral colours in the panel move onto tokens so it reads correctly in dark mode. --- ui/litellm-dashboard/eslint-suppressions.json | 6 - .../_components/ToolArgumentsForm.tsx | 330 +++++++++++ .../_components/ToolTestPanel.test.tsx | 182 ++++++ .../mcp-servers/_components/ToolTestPanel.tsx | 525 ++++-------------- .../_components/toolCallArguments.test.ts | 188 +++++++ .../_components/toolCallArguments.ts | 121 ++++ 6 files changed, 927 insertions(+), 425 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/toolCallArguments.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/toolCallArguments.ts diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 9c475500b15..d6d67e19233 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -715,12 +715,6 @@ } }, "src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": { - "no-nested-ternary": { - "count": 3 - }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx new file mode 100644 index 00000000000..0a212a75345 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx @@ -0,0 +1,330 @@ +import React from "react"; +import { useForm, type Control } from "react-hook-form"; +import { CircleHelp } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField, type FormFieldControlProps } from "@/components/shared/form/FormField"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { InputSchemaProperty } from "@/components/mcp_tools/types"; +import { + ToolArgumentField, + ToolArgumentsFormValues, + buildToolCallArguments, + toolArgumentsResolver, +} from "./toolCallArguments"; + +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; +}; + +const argumentLabel = (field: ToolArgumentField): React.ReactNode => ( + + {field.key} + {field.required && *} + {field.prop.description && ( + + } /> + {field.prop.description} + + )} + +); + +const BOOLEAN_ITEMS = [ + { value: true, label: "True" }, + { value: false, label: "False" }, +]; + +const booleanTitle = (value: unknown): string | undefined => { + if (value === true) return "True"; + if (value === false) return "False"; + return undefined; +}; + +const JsonArgumentControl: React.FC<{ + field: ToolArgumentField; + control: FormFieldControlProps; +}> = ({ field, control }) => { + const isObject = field.prop.type === "object"; + const fallbackPlaceholder = isObject ? `Enter JSON object for ${field.key}` : `Enter JSON array for ${field.key}`; + return ( +
+