diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 6fc646e0069..e11ff09d41b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2354,7 +2354,7 @@ }, "src/components/mcp_tools/MCPToolArgumentsForm.tsx": { "no-nested-ternary": { - "count": 5 + "count": 1 }, "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx new file mode 100644 index 00000000000..2b192f1777d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx @@ -0,0 +1,195 @@ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MCPToolsetsTab } from "./MCPToolsetsTab"; +import * as networking from "@/components/networking"; +import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; +import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; +import { MCPToolset } from "@/components/mcp_tools/types"; + +vi.mock("@/components/networking", () => ({ + createMCPToolset: vi.fn(), + updateMCPToolset: vi.fn(), + deleteMCPToolset: vi.fn(), + listMCPTools: vi.fn(), + getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"), +})); + +vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({ useMCPToolsets: vi.fn() })); +vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({ useMCPServers: vi.fn() })); + +const setup = () => userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + +const renderTab = (toolsets: MCPToolset[] = []) => { + vi.mocked(useMCPToolsets).mockReturnValue({ + data: toolsets, + isLoading: false, + } as unknown as ReturnType); + vi.mocked(useMCPServers).mockReturnValue({ data: [] } as unknown as ReturnType); + render( + + + , + ); +}; + +const dialogWithButton = async (name: string) => { + const button = await screen.findByRole("button", { name }); + const dialog = button.closest('[role="dialog"]'); + if (dialog === null) { + throw new Error(`no dialog contains a "${name}" button`); + } + return within(dialog as HTMLElement); +}; + +const openEditFor = async (user: ReturnType) => { + await user.click(await screen.findByRole("button", { name: "Open toolset actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Edit" })); + return dialogWithButton("Save Changes"); +}; + +const openCreate = async (user: ReturnType) => { + await user.click(screen.getByRole("button", { name: /new toolset/i })); + return dialogWithButton("Create Toolset"); +}; + +describe("MCPToolsetsTab create/edit toolset form", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a toolset with the typed name, description and no tools", async () => { + const user = setup(); + vi.mocked(networking.createMCPToolset).mockResolvedValue( + {} as Awaited>, + ); + renderTab(); + + const dialog = await openCreate(user); + await user.type(dialog.getByPlaceholderText("e.g. github-linear-tools"), "github-linear-tools"); + await user.type(dialog.getByPlaceholderText("Optional description"), "tools for triage"); + await user.click(dialog.getByRole("button", { name: "Create Toolset" })); + + await waitFor(() => { + expect(networking.createMCPToolset).toHaveBeenCalledWith("sk-test", { + toolset_name: "github-linear-tools", + description: "tools for triage", + tools: [], + }); + }); + expect(networking.createMCPToolset).toHaveBeenCalledTimes(1); + }); + + it("sends an empty string when the description is left untouched", async () => { + const user = setup(); + vi.mocked(networking.createMCPToolset).mockResolvedValue( + {} as Awaited>, + ); + renderTab(); + + const dialog = await openCreate(user); + await user.type(dialog.getByPlaceholderText("e.g. github-linear-tools"), "solo"); + await user.click(dialog.getByRole("button", { name: "Create Toolset" })); + + await waitFor(() => { + expect(networking.createMCPToolset).toHaveBeenCalledWith("sk-test", { + toolset_name: "solo", + description: "", + tools: [], + }); + }); + }); + + it("blocks the submit and shows the required message when the name is empty", async () => { + const user = setup(); + renderTab(); + + const dialog = await openCreate(user); + await user.click(dialog.getByRole("button", { name: "Create Toolset" })); + + expect(await dialog.findByText("Please enter a toolset name")).toBeInTheDocument(); + expect(networking.createMCPToolset).not.toHaveBeenCalled(); + }); + + it("does not treat a whitespace-only description as absent", async () => { + const user = setup(); + vi.mocked(networking.createMCPToolset).mockResolvedValue( + {} as Awaited>, + ); + renderTab(); + + const dialog = await openCreate(user); + await user.type(dialog.getByPlaceholderText("e.g. github-linear-tools"), "spaced"); + await user.type(dialog.getByPlaceholderText("Optional description"), " "); + await user.click(dialog.getByRole("button", { name: "Create Toolset" })); + + await waitFor(() => { + expect(networking.createMCPToolset).toHaveBeenCalledWith("sk-test", { + toolset_name: "spaced", + description: " ", + tools: [], + }); + }); + }); + + it("seeds the edit form from the toolset and updates it by id", async () => { + const user = setup(); + vi.mocked(networking.updateMCPToolset).mockResolvedValue( + {} as Awaited>, + ); + const toolset = { + toolset_id: "ts-1", + toolset_name: "existing", + description: "old description", + tools: [{ server_id: "srv-1", tool_name: "search" }], + } as MCPToolset; + renderTab([toolset]); + + const dialog = await openEditFor(user); + const name = await dialog.findByDisplayValue("existing"); + expect(name).toBe(dialog.getByPlaceholderText("e.g. github-linear-tools")); + expect(dialog.getByText("Toolset Name")).toBeInTheDocument(); + expect(dialog.getByPlaceholderText("Optional description")).toHaveValue("old description"); + + await user.clear(name); + await user.type(name, "renamed"); + await user.click(dialog.getByRole("button", { name: "Save Changes" })); + + const expectedUpdate = { + toolset_id: "ts-1", + toolset_name: "renamed", + description: "old description", + tools: [{ server_id: "srv-1", tool_name: "search" }], + }; + await waitFor(() => { + expect(networking.updateMCPToolset).toHaveBeenCalledWith("sk-test", expectedUpdate); + }); + }); + + it("seeds an absent description as an empty string rather than failing", async () => { + const user = setup(); + vi.mocked(networking.updateMCPToolset).mockResolvedValue( + {} as Awaited>, + ); + const toolset = { + toolset_id: "ts-2", + toolset_name: "no-desc", + description: null, + tools: [], + } as unknown as MCPToolset; + renderTab([toolset]); + + const dialog = await openEditFor(user); + await dialog.findByDisplayValue("no-desc"); + expect(dialog.getByPlaceholderText("Optional description")).toHaveValue(""); + + await user.click(dialog.getByRole("button", { name: "Save Changes" })); + + const expectedUpdate = { toolset_id: "ts-2", toolset_name: "no-desc", description: "", tools: [] }; + await waitFor(() => { + expect(networking.updateMCPToolset).toHaveBeenCalledWith("sk-test", expectedUpdate); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index a7a56fe040f..8d0901d7176 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -1,7 +1,6 @@ import React, { useState, useCallback } from "react"; -import { Button } from "@/components/ui/button"; -import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; -import { Modal, Form, Input, message, Spin } from "antd"; +import { Modal, Input, message, Spin } from "antd"; +import { z } from "zod/v4"; import { SortingState } from "@tanstack/react-table"; import { Inbox, Plus } from "lucide-react"; import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; @@ -16,6 +15,12 @@ import { getProxyBaseUrl, } from "@/components/networking"; import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { Input as ShadcnInput } from "@/components/ui/input"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { useZodForm } from "@/lib/forms/useZodForm"; import { displayToolName, getMCPToolsetTableColumns } from "./MCPToolsetTableColumns"; interface MCPToolsetsTabProps { @@ -23,10 +28,12 @@ interface MCPToolsetsTabProps { userRole: string | null; } -interface ToolsetFormValues { - toolset_name: string; - description?: string; -} +const toolsetSchema = z.object({ + toolset_name: z.string().min(1, "Please enter a toolset name"), + description: z.string(), +}); + +type ToolsetFormValues = z.infer; interface MCPToolListProps { serverId: string; @@ -68,20 +75,22 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl }; return ( -
+
{expanded && (
@@ -90,7 +99,7 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
) : tools.length === 0 ? ( -

No tools found for this server.

+

No tools found for this server.

) : (
{tools.map((tool) => { @@ -102,21 +111,27 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl onClick={() => onToggle({ server_id: serverId, tool_name: tool.name })} className={`flex items-start justify-between px-3 py-2 rounded-lg text-left transition-colors ${ selected - ? "bg-purple-50 border border-purple-300" - : "bg-white border border-gray-100 hover:bg-gray-50" + ? "bg-purple-50 border border-purple-300 dark:bg-purple-950 dark:border-purple-700" + : "bg-card border border-border hover:bg-muted" }`} >

{tool.name}

{tool.description && ( -

{tool.description}

+

+ {tool.description} +

)}
- {selected && ✓} + {selected && ( + + ✓ + + )} ); })} @@ -137,7 +152,12 @@ interface CreateToolsetModalProps { } function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) { - const [form] = Form.useForm(); + const form = useZodForm(toolsetSchema, { + defaultValues: { + toolset_name: initialToolset?.toolset_name || "", + description: initialToolset?.description || "", + }, + }); const [selectedTools, setSelectedTools] = useState(initialToolset?.tools || []); const [saving, setSaving] = useState(false); const [serverSearch, setServerSearch] = useState(""); @@ -149,14 +169,14 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset React.useEffect(() => { if (open) { - form.setFieldsValue({ + form.reset({ toolset_name: initialToolset?.toolset_name || "", description: initialToolset?.description || "", }); setSelectedTools(initialToolset?.tools || []); setServerSearch(""); } - }, [open, initialToolset]); + }, [open, initialToolset, form]); const handleToggleTool = (tool: MCPToolsetTool) => { setSelectedTools((prev) => { @@ -167,8 +187,7 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }); }; - const handleSubmit = async () => { - const values = await form.validateFields(); + const handleSubmit = async (values: ToolsetFormValues) => { setSaving(true); try { await onSave(values.toolset_name, values.description, selectedTools); @@ -192,27 +211,22 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset footer={null} forceRender > -
-
- - - - - - -
-
+
event.preventDefault()} className="mt-2"> + + + {(field) => } + + + {(field) => } + + +
{/* Left panel: Available Tools */}
-

Available Tools

+

Available Tools

{filteredServers.length === 0 ? ( -

+

{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"}

) : ( @@ -242,31 +256,36 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
{/* Divider */} -
+
{/* Right panel: Your Toolset */}
-

- Your Toolset ({selectedTools.length} tools) +

+ Your Toolset{" "} + ({selectedTools.length} tools)

{selectedTools.length === 0 ? ( -

No tools added yet

+

No tools added yet

) : ( selectedTools.map((tool, idx) => ( )) )} @@ -274,11 +293,11 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
-
- - @@ -325,22 +344,22 @@ function ToolsetUsageGuide() { }; return ( -
-

How toolsets work

-

+

+

How toolsets work

+

Create a toolset, assign it to a key via{" "} - API Keys → Edit Key → MCP Servers, then point your MCP client - at the toolset URL. The client only sees the tools you picked. + API Keys → Edit Key → MCP Servers, then point your MCP + client at the toolset URL. The client only sees the tools you picked.

-
Claude Code / Cursor config
+
Claude Code / Cursor config
-
+        
           {snippet}
         
@@ -407,8 +426,8 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
-

MCP Toolsets

-

+

MCP Toolsets

+

Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown.

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.integration.test.tsx new file mode 100644 index 00000000000..9c91a25cf37 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.integration.test.tsx @@ -0,0 +1,211 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import UserEnvVarsModal from "./UserEnvVarsModal"; +import * as networking from "@/components/networking"; +import { MCPServer, MCPUserEnvVarsStatus } from "@/components/mcp_tools/types"; + +vi.mock("@/components/networking", () => ({ + getMCPUserEnvVars: vi.fn(), + storeMCPUserEnvVars: vi.fn(), +})); + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + +const setup = () => userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + +const server = { server_id: "srv-1", server_name: "Payments", alias: "payments" } as MCPServer; + +const statusWith = (required: MCPUserEnvVarsStatus["required"]): MCPUserEnvVarsStatus => + ({ required }) as MCPUserEnvVarsStatus; + +const renderModal = (status: MCPUserEnvVarsStatus, onSaved = vi.fn(), onClose = vi.fn()) => { + vi.mocked(networking.getMCPUserEnvVars).mockResolvedValue(status); + render( + + + , + ); + return { onSaved, onClose }; +}; + +const save = (user: ReturnType) => user.click(screen.getByRole("button", { name: "Save Credentials" })); + +const fieldAfterOpen = async (label: RegExp): Promise => { + const initial = await screen.findByLabelText(label); + await waitFor(() => { + expect(initial).not.toBeInTheDocument(); + }); + return screen.getByLabelText(label); +}; + +describe("UserEnvVarsModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("submits every declared field, trimmed, keyed by env var name", async () => { + const user = setup(); + vi.mocked(networking.storeMCPUserEnvVars).mockResolvedValue(statusWith([])); + renderModal( + statusWith([ + { name: "API_KEY", description: "Your API key", is_set: false }, + { name: "REGION", description: null, is_set: false }, + ]), + ); + + await user.type(await fieldAfterOpen(/^API_KEY/), " secret-value "); + await user.type(screen.getByLabelText(/^REGION/), "us-east-1"); + await save(user); + + await waitFor(() => { + expect(networking.storeMCPUserEnvVars).toHaveBeenCalledWith("sk-test", "srv-1", { + API_KEY: "secret-value", + REGION: "us-east-1", + }); + }); + expect(networking.storeMCPUserEnvVars).toHaveBeenCalledTimes(1); + }); + + it("sends an empty string for an already-set field left blank", async () => { + const user = setup(); + vi.mocked(networking.storeMCPUserEnvVars).mockResolvedValue(statusWith([])); + renderModal( + statusWith([ + { name: "API_KEY", description: null, is_set: true }, + { name: "REGION", description: null, is_set: true }, + ]), + ); + + await user.type(await fieldAfterOpen(/^REGION/), "eu-west-2"); + await save(user); + + await waitFor(() => { + expect(networking.storeMCPUserEnvVars).toHaveBeenCalledWith("sk-test", "srv-1", { + API_KEY: "", + REGION: "eu-west-2", + }); + }); + }); + + it("blocks the submit and shows the required message when an unset field is empty", async () => { + const user = setup(); + renderModal( + statusWith([ + { name: "API_KEY", description: null, is_set: false }, + { name: "REGION", description: null, is_set: true }, + ]), + ); + + await fieldAfterOpen(/^API_KEY/); + await save(user); + + expect(await screen.findByText("API_KEY is required")).toBeInTheDocument(); + expect(networking.storeMCPUserEnvVars).not.toHaveBeenCalled(); + }); + + it("does not require an already-set field", async () => { + const user = setup(); + vi.mocked(networking.storeMCPUserEnvVars).mockResolvedValue(statusWith([])); + renderModal(statusWith([{ name: "API_KEY", description: null, is_set: true }])); + + await fieldAfterOpen(/^API_KEY/); + await save(user); + + await waitFor(() => { + expect(networking.storeMCPUserEnvVars).toHaveBeenCalledWith("sk-test", "srv-1", { API_KEY: "" }); + }); + }); + + it("renders the admin description as the placeholder for an unset field", async () => { + renderModal(statusWith([{ name: "API_KEY", description: "Grab it from the console", is_set: false }])); + + expect(await screen.findByPlaceholderText("Grab it from the console")).toBeInTheDocument(); + }); + + it("renders the overwrite placeholder and a Set marker for an already-set field", async () => { + renderModal(statusWith([{ name: "API_KEY", description: "Grab it from the console", is_set: true }])); + + expect(await screen.findByPlaceholderText("Enter a new value to overwrite")).toBeInTheDocument(); + expect(screen.getByText("Set")).toBeInTheDocument(); + }); + + it("renders the admin description as always-visible help text", async () => { + renderModal(statusWith([{ name: "API_KEY", description: "Grab it from the console", is_set: true }])); + + await fieldAfterOpen(/^API_KEY/); + expect(screen.getByText("Grab it from the console")).toBeVisible(); + }); + + it("masks the entered value", async () => { + const user = setup(); + renderModal(statusWith([{ name: "API_KEY", description: null, is_set: false }])); + + const input = await fieldAfterOpen(/^API_KEY/); + expect(input).toHaveAttribute("type", "password"); + await user.type(input, "hunter2"); + expect(screen.getByLabelText(/^API_KEY/)).toHaveAttribute("type", "password"); + }); + + it("reports the empty state instead of a form when nothing is required", async () => { + renderModal(statusWith([])); + + expect(await screen.findByText("No per-user fields configured for this server.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save Credentials" })).not.toBeInTheDocument(); + }); + + it("closes and reports the saved status on success", async () => { + const user = setup(); + const saved = statusWith([{ name: "API_KEY", description: null, is_set: true }]); + vi.mocked(networking.storeMCPUserEnvVars).mockResolvedValue(saved); + const { onSaved, onClose } = renderModal(statusWith([{ name: "API_KEY", description: null, is_set: false }])); + + await user.type(await fieldAfterOpen(/^API_KEY/), "abc"); + await save(user); + + await waitFor(() => { + expect(onSaved).toHaveBeenCalledWith(saved); + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("reveals and re-masks the value through the visibility toggle", async () => { + const user = setup(); + renderModal(statusWith([{ name: "API_KEY", description: null, is_set: false }])); + + await user.type(await fieldAfterOpen(/^API_KEY/), "hunter2"); + await user.click(screen.getByRole("button", { name: "Show password" })); + expect(screen.getByLabelText(/^API_KEY/)).toHaveAttribute("type", "text"); + expect(screen.getByLabelText(/^API_KEY/)).toHaveValue("hunter2"); + + await user.click(screen.getByRole("button", { name: "Hide password" })); + expect(screen.getByLabelText(/^API_KEY/)).toHaveAttribute("type", "password"); + }); + + it("does not submit when the visibility toggle is clicked", async () => { + const user = setup(); + renderModal(statusWith([{ name: "API_KEY", description: null, is_set: true }])); + + await fieldAfterOpen(/^API_KEY/); + await user.click(screen.getByRole("button", { name: "Show password" })); + + expect(networking.storeMCPUserEnvVars).not.toHaveBeenCalled(); + }); + + it("surfaces a save failure without closing", async () => { + const user = setup(); + vi.mocked(networking.storeMCPUserEnvVars).mockRejectedValue(new Error("boom")); + const { onSaved, onClose } = renderModal(statusWith([{ name: "API_KEY", description: null, is_set: false }])); + + await user.type(await fieldAfterOpen(/^API_KEY/), "abc"); + await save(user); + + await waitFor(() => { + expect(networking.storeMCPUserEnvVars).toHaveBeenCalledTimes(1); + }); + expect(onSaved).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx index e92567c3974..df97f05c6fe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx @@ -1,9 +1,16 @@ import React from "react"; -import { Modal, Form, Input, Button, Alert, Spin, Tag, Typography } from "antd"; +import { Modal, Alert, Spin, Tag, Typography } from "antd"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { MCPServer, MCPUserEnvVarsStatus } from "@/components/mcp_tools/types"; +import { z } from "zod/v4"; +import { MCPServer, MCPUserEnvVarsStatus, MCPUserEnvVarSpec } from "@/components/mcp_tools/types"; import { getMCPUserEnvVars, storeMCPUserEnvVars } from "@/components/networking"; import { toast } from "@/lib/toast"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { PasswordInput } from "@/components/shared/PasswordInput"; +import { Button } from "@/components/ui/button"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { useZodForm } from "@/lib/forms/useZodForm"; const { Text, Title } = Typography; @@ -15,6 +22,67 @@ interface UserEnvVarsModalProps { onSaved?: (status: MCPUserEnvVarsStatus) => void; } +interface UserEnvVarsFormProps { + required: readonly MCPUserEnvVarSpec[]; + isSaving: boolean; + onCancel: () => void; + onSubmit: (values: Record) => void; +} + +const buildSchema = (required: readonly MCPUserEnvVarSpec[]) => + z.object( + Object.fromEntries( + required.map((spec) => [spec.name, spec.is_set ? z.string() : z.string().min(1, `${spec.name} is required`)]), + ), + ); + +const emptyValues = (required: readonly MCPUserEnvVarSpec[]): Record => + Object.fromEntries(required.map((spec) => [spec.name, ""])); + +const UserEnvVarsForm: React.FC = ({ required, isSaving, onCancel, onSubmit }) => { + const form = useZodForm(buildSchema(required), { defaultValues: emptyValues(required) }); + + return ( +
+ + {required.map((spec) => ( + + {spec.name} + {spec.is_set && Set} + + } + > + {(field) => ( + + )} + + ))} + +
+ + +
+
+ ); +}; + /** * User-facing modal for filling in per-user MCP environment variables. * @@ -23,7 +91,7 @@ interface UserEnvVarsModalProps { * description as the placeholder. */ const UserEnvVarsModal: React.FC = ({ server, open, accessToken, onClose, onSaved }) => { - const [form] = Form.useForm(); + const [formGeneration, setFormGeneration] = React.useState(0); const { data: status, @@ -68,7 +136,7 @@ const UserEnvVarsModal: React.FC = ({ server, open, acces width={520} destroyOnHidden afterOpenChange={(opened) => { - if (opened) form.resetFields(); + if (opened) setFormGeneration((generation) => generation + 1); }} title={
@@ -95,42 +163,18 @@ const UserEnvVarsModal: React.FC = ({ server, open, acces ) : ( <> - + These values are private to you. Your admin configured this MCP server to require these per-user credentials. Saved values are never shown back; leave an already-set field blank to keep it, or enter a value to set or change it. -
- {required.map((spec) => ( - - {spec.name} - {spec.is_set && Set} - - } - extra={spec.description || undefined} - rules={spec.is_set ? undefined : [{ required: true, message: `${spec.name} is required` }]} - > - - - ))} -
- - -
-
+ )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx index e32b968c1d3..066b26a9832 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/no-unescaped-entities */ import React, { useState } from "react"; -import { Card, Typography, Space, Alert, Button, Switch, Form } from "antd"; +import { Card, Typography, Space, Alert, Button, Switch } from "antd"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react"; import { getProxyBaseUrl } from "@/components/networking"; @@ -49,18 +49,18 @@ const FeatureCard: React.FC = ({ }; return ( - +
- {icon} + {icon}
{title} - {description} + {description}
{serverName && (title === "Implementation Example" || title === "Configuration") && ( - +
@@ -81,14 +81,14 @@ const FeatureCard: React.FC = ({

Option 2: Get a group of MCPs: "dev-group"

-

+

You can also mix both: "Server1,dev-group"

} /> )} - +
)} {React.Children.map(children, (child) => { if ( @@ -137,13 +137,13 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = []
{title && (
- - + + {title}
)} - +
); @@ -167,12 +167,12 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] }> = ({ step, title, children }) => (
-
+
{step}
- + {title} {children} @@ -254,21 +254,21 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] const OpenAITab = () => ( -
+
- - + <Code className="text-blue-600 dark:text-blue-400" size={24} /> + <Title level={4} className="mb-0 text-blue-900 dark:text-blue-100"> OpenAI Responses API Integration
- + Connect OpenAI Responses API to your LiteLLM MCP server for seamless tool integration
} + icon={} title="API Key Setup" description="Configure your OpenAI API key for authentication" > @@ -281,7 +281,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" - className="text-blue-600 hover:text-blue-700 inline-flex items-center gap-1" + className="text-blue-600 hover:text-blue-700 inline-flex items-center gap-1 dark:text-blue-400 dark:hover:text-blue-300" > OpenAI platform @@ -292,7 +292,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] } + icon={} title="MCP Server Information" description="Connection details for your LiteLLM MCP server" > @@ -300,7 +300,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] } + icon={} title="Implementation Example" description="Complete cURL example for using the Responses API" serverName="Zapier Gmail" @@ -350,27 +350,27 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = []
- - + <Card className="border border-border"> + <Title level={5} className="mb-4 text-foreground"> Setup Instructions - - Use the keyboard shortcut ⇧+⌘+J (Mac) or{" "} - Ctrl+Shift+J (Windows/Linux) + + Use the keyboard shortcut ⇧+⌘+J (Mac) or{" "} + Ctrl+Shift+J (Windows/Linux) - Go to the "MCP Tools" tab and click "New MCP Server" + Go to the "MCP Tools" tab and click "New MCP Server" - + Copy the JSON configuration below and paste it into Cursor, then save with{" "} - Cmd+S or{" "} - Ctrl+S + Cmd+S or{" "} + Ctrl+S } @@ -405,18 +405,18 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = []
- - + <Globe className="text-green-600 dark:text-green-400" size={24} /> + <Title level={4} className="mb-0 text-green-900 dark:text-green-100"> Streamable HTTP Transport
- + Connect to LiteLLM MCP using HTTP transport. Compatible with any MCP client that supports HTTP streaming.
} + icon={} title="Universal MCP Connection" description="Use this URL with any MCP client that supports HTTP transport" > @@ -442,7 +442,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = []