diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.integration.test.tsx similarity index 61% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.test.tsx rename to ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.integration.test.tsx index d6e614da5eb..b1174d1d37d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.integration.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "./MCPToolArgumentsForm"; @@ -26,6 +26,118 @@ const submitError = async (ref: React.RefObject) }; describe("MCPToolArgumentsForm", () => { + it("keeps dotted arguments separate from a same-prefix object and converts their values", async () => { + const ref = renderForm({ + type: "object", + properties: { + "filter.category": { type: "string" }, + filter: { type: "object" }, + "page.limit": { type: "integer" }, + query: { type: "string" }, + }, + required: ["filter.category"], + }); + + fireEvent.change(screen.getByRole("textbox", { name: "filter.category *" }), { + target: { value: "invoices" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "filter" }), { + target: { value: '{"category":"receipts","metadata":{"region":"eu"}}' }, + }); + fireEvent.change(screen.getByRole("spinbutton", { name: "page.limit" }), { target: { value: "7" } }); + fireEvent.change(screen.getByRole("textbox", { name: "query" }), { target: { value: "September" } }); + + const expected = { + "filter.category": "invoices", + filter: { category: "receipts", metadata: { region: "eu" } }, + "page.limit": 7, + query: "September", + }; + await expect(submit(ref)).resolves.toEqual(expected); + }); + + it("shows required validation on the literal dotted field and accepts a correction", async () => { + const ref = renderForm({ + type: "object", + properties: { "filter.category": { type: "string" } }, + required: ["filter.category"], + }); + + expect(await submitError(ref)).toEqual({ + errorFields: [{ name: ["filter.category"], errors: ["Please enter filter.category"] }], + }); + expect(await screen.findByText("Please enter filter.category")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "filter.category *" })).toHaveAttribute("aria-invalid", "true"); + + fireEvent.change(screen.getByRole("textbox", { name: "filter.category *" }), { + target: { value: "invoices" }, + }); + await expect(submit(ref)).resolves.toEqual({ "filter.category": "invoices" }); + }); + + it("validates JSON for dotted arguments inside params and preserves their literal names", async () => { + const ref = renderForm({ + type: "object", + properties: { + params: { + type: "object", + properties: { "filter.options": { type: "object" } }, + required: ["filter.options"], + }, + }, + required: [], + }); + const field = screen.getByRole("textbox", { name: "filter.options *" }); + fireEvent.change(field, { target: { value: "invalid" } }); + + expect(await submitError(ref)).toEqual({ + errorFields: [{ name: ["filter.options"], errors: ["Invalid JSON"] }], + }); + expect(await screen.findByText("Invalid JSON")).toBeInTheDocument(); + + fireEvent.change(field, { target: { value: '{"region":"eu"}' } }); + await expect(submit(ref)).resolves.toEqual({ params: { "filter.options": { region: "eu" } } }); + }); + + it("resets dotted defaults and positional values when the selected tool changes", async () => { + const ref = React.createRef(); + const { rerender } = render( + , + ); + expect(screen.getByRole("textbox", { name: "filter.category" })).toHaveValue("invoices"); + await expect(submit(ref)).resolves.toEqual({ "filter.category": "invoices" }); + fireEvent.change(screen.getByRole("textbox", { name: "filter.category" }), { + target: { value: "edited" }, + }); + await expect(submit(ref)).resolves.toEqual({ "filter.category": "edited" }); + + rerender( + , + ); + expect(screen.getByRole("textbox", { name: "filter.category" })).toHaveValue("receipts"); + await expect(submit(ref)).resolves.toEqual({ query: "new tool", "filter.category": "receipts" }); + }); + it("returns typed values for a string, integer, number and boolean field", async () => { const user = userEvent.setup(); const ref = renderForm({ diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx index ca8c5697e6e..ab3213d9b37 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx @@ -1,6 +1,6 @@ import React, { forwardRef, useImperativeHandle, useMemo } from "react"; import { CircleHelp } from "lucide-react"; -import { useForm, type Resolver } from "react-hook-form"; +import { useForm, type Resolver, type ResolverResult } from "react-hook-form"; import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; @@ -9,7 +9,10 @@ import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { MCPTool, InputSchema, InputSchemaProperty } from "./types"; -type ToolFormValues = Record; +type ToolFormValues = { args: unknown[] }; + +const argumentValues = (schema: InputSchema, values: ToolFormValues): Record => + Object.fromEntries(Object.keys(schema.properties ?? {}).map((key, index) => [key, values.args[index]])); const STRING_SCHEMA_MESSAGES: Readonly> = { input: "Please enter input for this tool" }; @@ -38,7 +41,7 @@ type FieldError = { type: string; message: string }; const collectErrors = ( actualSchema: InputSchema, requiredMessages: Readonly>, - values: ToolFormValues, + values: Record, ): Record => { const entries = Object.entries(actualSchema.properties ?? {}).flatMap<[string, FieldError]>(([key, prop]) => { const value = values[key]; @@ -56,9 +59,19 @@ const collectErrors = ( const buildResolver = (actualSchema: InputSchema, requiredMessages: Readonly> = {}): Resolver => - (values) => { - const errors = collectErrors(actualSchema, requiredMessages, values); - return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} }; + (values): ResolverResult => { + const errors = collectErrors(actualSchema, requiredMessages, argumentValues(actualSchema, values)); + if (Object.keys(errors).length === 0) return { values, errors: {} }; + return { + values: {}, + errors: { + args: Object.fromEntries( + Object.keys(actualSchema.properties ?? {}).flatMap((key, index) => + Object.hasOwn(errors, key) ? [[index, errors[key]]] : [], + ), + ), + }, + }; }; const labelFor = (key: string, prop: InputSchemaProperty, required: boolean): React.ReactNode => ( @@ -238,10 +251,7 @@ const MCPToolArgumentsForm = forwardRef( - () => - Object.fromEntries( - Object.entries(actualSchema.properties ?? {}).map(([key, prop]) => [key, getInitialValueForField(prop)]), - ), + () => ({ args: Object.values(actualSchema.properties ?? {}).map(getInitialValueForField) }), [actualSchema], ); @@ -255,7 +265,7 @@ const MCPToolArgumentsForm = forwardRef ({ getSubmitValues: async () => { - const values = form.getValues(); + const values = argumentValues(actualSchema, form.getValues()); const errors = collectErrors(actualSchema, requiredMessages, values); if (Object.keys(errors).length > 0) { await form.trigger(); @@ -286,14 +296,16 @@ const MCPToolArgumentsForm = forwardRef Input * } > - {(field) => } + {(field) => ( + + )} @@ -318,13 +330,13 @@ const MCPToolArgumentsForm = forwardRef - {Object.entries(actualSchema.properties).map(([key, prop]) => { + {Object.entries(actualSchema.properties).map(([key, prop], index) => { const required = actualSchema.required?.includes(key) ?? false; return ( {(field) => { @@ -375,7 +387,7 @@ const MCPToolArgumentsForm = forwardRef ); @@ -385,7 +397,7 @@ const MCPToolArgumentsForm = forwardRef );