mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
refactor(ui): migrate the MCP per-user env vars, toolset and tool arguments forms to react-hook-form and shadcn (#37349)
* refactor(ui): migrate the MCP per-user env vars modal to react-hook-form and shadcn Moves UserEnvVarsModal off the antd Form store onto react-hook-form with a zod schema built from the server's declared per-user variables, and swaps antd Input.Password for the shared PasswordInput. The submit payload is unchanged: every declared variable is still sent as a key, trimmed, with an untouched field sending an empty string. antd reset the store from the modal's afterOpenChange; the migrated form reproduces that by remounting on the same callback, so reopening still starts blank. Adds UserEnvVarsModal.test.tsx, which was written against the antd original and proven green before any production change, then re-run unedited against the migration. Two further cases cover the reveal toggle, which antd provided through visibilityToggle. * refactor(ui): migrate the MCP toolset create and edit form to react-hook-form and shadcn Moves the toolset name and description fields off the antd Form store onto react-hook-form with a zod schema, and takes the surrounding panel onto semantic colour tokens so the tab renders in dark mode. The purple selected tool styling keeps its hue and gains dark variants rather than flattening to neutral. Payload is unchanged: create still sends toolset_name, description and tools, an untouched description is still the empty string rather than undefined, and the tool selection is still held outside the form. The antd form carried no onFinish and its buttons sit outside the form element, so the migrated form keeps submit on the footer button and neutralises its own submit rather than introducing Enter to save. Adds MCPToolsetsTab.test.tsx, proven green against the antd original before any production change and re-run unedited afterwards. * refactor(ui): migrate the MCP tool arguments form to react-hook-form and shadcn Moves the schema-driven tool argument form off the antd Form store onto react-hook-form. Validation moves to an explicit resolver that reproduces antd's rules field by field, including the per-field required message and the JSON object and array messages, and the same resolver is reused by getSubmitValues so the imperative path and the rendered errors cannot disagree. getSubmitValues still rejects with a plain object carrying errorFields rather than an Error. ChatUI branches on `err instanceof Error` to choose its toast, so rejecting with an Error would have silently changed the message the user sees. That is pinned by a test proven green against the antd original with a Form.Item liveness gate, and proven red when the rejection is switched to an Error. Enum and boolean fields keep the antd Select, whose allowClear has no shadcn equivalent; dropping it would remove the only way to unset an optional enum. Everything else moves to the shadcn Input and Textarea and onto semantic colour tokens. Adds MCPToolArgumentsForm.test.tsx covering the string, integer, number, boolean, object, array, nested-params and string-schema paths, written against the antd original and re-run unedited afterwards. * refactor(ui): drop the decorative antd Form.Item from the MCP connect guide The connect guide rendered a single antd Form.Item with no field name and no Form ancestor, so it registered nothing and carried no payload; it was only supplying bottom margin. It becomes a div with the same margin class, which removes the file's last antd Form dependency. Also takes the guide onto semantic colour tokens so it renders in dark mode. The blue and green callouts keep their hue and gain dark variants rather than flattening to neutral, since the colour carries meaning there. * chore(ui): ratchet the MCP tool arguments form lint suppressions The react-hook-form migration removed four of the five nested ternaries in MCPToolArgumentsForm, so lower the grandfathered count to match and hoist the one inline object literal the budget rule flags. * test(ui): classify the MCP modal batteries as integration tests Both render a real component tree down to the form controls and stub only the network boundary, which is the repo's definition of an integration test rather than a unit test. The tool arguments battery renders a single module in milliseconds, so it stays unsuffixed.
This commit is contained in:
parent
2502776700
commit
c180849210
8 changed files with 997 additions and 268 deletions
|
|
@ -2354,7 +2354,7 @@
|
|||
},
|
||||
"src/components/mcp_tools/MCPToolArgumentsForm.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 5
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -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<typeof useMCPToolsets>);
|
||||
vi.mocked(useMCPServers).mockReturnValue({ data: [] } as unknown as ReturnType<typeof useMCPServers>);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })}>
|
||||
<MCPToolsetsTab accessToken="sk-test" userRole="Admin" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
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<typeof setup>) => {
|
||||
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<typeof setup>) => {
|
||||
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<ReturnType<typeof networking.createMCPToolset>>,
|
||||
);
|
||||
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<ReturnType<typeof networking.createMCPToolset>>,
|
||||
);
|
||||
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<ReturnType<typeof networking.createMCPToolset>>,
|
||||
);
|
||||
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<ReturnType<typeof networking.updateMCPToolset>>,
|
||||
);
|
||||
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<ReturnType<typeof networking.updateMCPToolset>>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof toolsetSchema>;
|
||||
|
||||
interface MCPToolListProps {
|
||||
serverId: string;
|
||||
|
|
@ -68,20 +75,22 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-muted hover:bg-accent transition-colors"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||
{serverName}
|
||||
{selectedSet.size > 0 && (
|
||||
<span className="ml-1 text-xs text-purple-600 font-semibold">{selectedSet.size} selected</span>
|
||||
<span className="ml-1 text-xs text-purple-600 font-semibold dark:text-purple-400">
|
||||
{selectedSet.size} selected
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-xs">{expanded ? "▲" : "▼"}</span>
|
||||
<span className="text-muted-foreground text-xs">{expanded ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="p-2">
|
||||
|
|
@ -90,7 +99,7 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
|
|||
<Spin size="small" />
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-2 py-2">No tools found for this server.</p>
|
||||
<p className="text-xs text-muted-foreground px-2 py-2">No tools found for this server.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{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"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium leading-tight ${selected ? "text-purple-800" : "text-gray-800"}`}
|
||||
className={`text-sm font-medium leading-tight ${selected ? "text-purple-800 dark:text-purple-200" : "text-foreground"}`}
|
||||
>
|
||||
{tool.name}
|
||||
</p>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 leading-tight line-clamp-2">{tool.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-tight line-clamp-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selected && <span className="text-purple-500 text-xs font-semibold ml-2 shrink-0 mt-0.5">✓</span>}
|
||||
{selected && (
|
||||
<span className="text-purple-500 text-xs font-semibold ml-2 shrink-0 mt-0.5 dark:text-purple-400">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -137,7 +152,12 @@ interface CreateToolsetModalProps {
|
|||
}
|
||||
|
||||
function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) {
|
||||
const [form] = Form.useForm<ToolsetFormValues>();
|
||||
const form = useZodForm(toolsetSchema, {
|
||||
defaultValues: {
|
||||
toolset_name: initialToolset?.toolset_name || "",
|
||||
description: initialToolset?.description || "",
|
||||
},
|
||||
});
|
||||
const [selectedTools, setSelectedTools] = useState<MCPToolsetTool[]>(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
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-2">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<Form.Item
|
||||
label="Toolset Name"
|
||||
name="toolset_name"
|
||||
rules={[{ required: true, message: "Please enter a toolset name" }]}
|
||||
className="flex-1 mb-0"
|
||||
>
|
||||
<Input placeholder="e.g. github-linear-tools" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Description" name="description" className="flex-1 mb-0">
|
||||
<Input placeholder="Optional description" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
<form onSubmit={(event) => event.preventDefault()} className="mt-2">
|
||||
<FieldGroup className="mb-4 flex-row gap-4">
|
||||
<FormField control={form.control} name="toolset_name" label="Toolset Name" className="flex-1">
|
||||
{(field) => <ShadcnInput {...field} placeholder="e.g. github-linear-tools" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="description" label="Description" className="flex-1">
|
||||
{(field) => <ShadcnInput {...field} placeholder="Optional description" />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-4 mt-2" style={{ minHeight: 360 }}>
|
||||
{/* Left panel: Available Tools */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-gray-700">Available Tools</p>
|
||||
<p className="text-sm font-semibold text-foreground">Available Tools</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search MCP servers..."
|
||||
|
|
@ -223,7 +237,7 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
|
|||
/>
|
||||
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
|
||||
{filteredServers.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"}
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -242,31 +256,36 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
|
|||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px bg-gray-200 shrink-0" />
|
||||
<div className="w-px bg-border shrink-0" />
|
||||
|
||||
{/* Right panel: Your Toolset */}
|
||||
<div className="w-72 shrink-0">
|
||||
<p className="text-sm font-semibold text-gray-700 mb-2 block">
|
||||
Your Toolset <span className="text-xs font-normal text-gray-400">({selectedTools.length} tools)</span>
|
||||
<p className="text-sm font-semibold text-foreground mb-2 block">
|
||||
Your Toolset{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">({selectedTools.length} tools)</span>
|
||||
</p>
|
||||
<div className="space-y-1 overflow-y-auto" style={{ maxHeight: 340 }}>
|
||||
{selectedTools.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">No tools added yet</p>
|
||||
<p className="text-muted-foreground text-sm">No tools added yet</p>
|
||||
) : (
|
||||
selectedTools.map((tool, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleToggleTool(tool)}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-red-50 hover:border-red-200 group transition-colors"
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-red-50 hover:border-red-200 group transition-colors dark:border-purple-800 dark:bg-purple-950 dark:hover:bg-red-950 dark:hover:border-red-800"
|
||||
>
|
||||
<div className="min-w-0 text-left">
|
||||
<span className="text-xs font-medium text-purple-800 group-hover:text-red-600 truncate block">
|
||||
<span className="text-xs font-medium text-purple-800 group-hover:text-red-600 truncate block dark:text-purple-200 dark:group-hover:text-red-400">
|
||||
{displayToolName(serverPrefixById.get(tool.server_id), tool.tool_name)}
|
||||
</span>
|
||||
<span className="text-[10px] text-purple-400 truncate block">{tool.server_id.slice(0, 8)}…</span>
|
||||
<span className="text-[10px] text-purple-400 truncate block dark:text-purple-500">
|
||||
{tool.server_id.slice(0, 8)}…
|
||||
</span>
|
||||
</div>
|
||||
<span className="ml-2 text-purple-300 group-hover:text-red-400 text-xs shrink-0">✕</span>
|
||||
<span className="ml-2 text-purple-300 group-hover:text-red-400 text-xs shrink-0 dark:text-purple-600">
|
||||
✕
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
|
@ -274,11 +293,11 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving} aria-busy={saving}>
|
||||
<Button onClick={() => void form.handleSubmit(handleSubmit)()} disabled={saving} aria-busy={saving}>
|
||||
{saving && <UiLoadingSpinner className="size-4" />}
|
||||
{initialToolset ? "Save Changes" : "Create Toolset"}
|
||||
</Button>
|
||||
|
|
@ -325,22 +344,22 @@ function ToolsetUsageGuide() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-gray-200 bg-gray-50 px-5 py-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">How toolsets work</p>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
<div className="mb-6 rounded-lg border border-border bg-muted px-5 py-4">
|
||||
<p className="text-sm font-medium text-foreground mb-1">How toolsets work</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Create a toolset, assign it to a key via{" "}
|
||||
<span className="font-medium text-gray-700">API Keys → Edit Key → MCP Servers</span>, then point your MCP client
|
||||
at the toolset URL. The client only sees the tools you picked.
|
||||
<span className="font-medium text-foreground">API Keys → Edit Key → MCP Servers</span>, then point your MCP
|
||||
client at the toolset URL. The client only sees the tools you picked.
|
||||
</p>
|
||||
<div className="text-xs text-gray-400 mb-1">Claude Code / Cursor config</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Claude Code / Cursor config</div>
|
||||
<div className="relative">
|
||||
<pre className="bg-white border border-gray-200 rounded-sm px-4 py-3 text-xs font-mono text-gray-700 overflow-x-auto leading-relaxed pr-14">
|
||||
<pre className="bg-card border border-border rounded-sm px-4 py-3 text-xs font-mono text-foreground overflow-x-auto leading-relaxed pr-14">
|
||||
{snippet}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs rounded-sm border bg-white hover:bg-gray-50 text-gray-400 hover:text-gray-600 border-gray-200 transition-colors"
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs rounded-sm border bg-card hover:bg-muted text-muted-foreground hover:text-foreground border-border transition-colors"
|
||||
>
|
||||
{copied ? "✓" : "copy"}
|
||||
</button>
|
||||
|
|
@ -407,8 +426,8 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
|||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">MCP Toolsets</h3>
|
||||
<p className="text-gray-500 text-sm">
|
||||
<h3 className="text-lg font-medium text-foreground">MCP Toolsets</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP
|
||||
permissions dropdown.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<UserEnvVarsModal server={server} open accessToken="sk-test" onClose={onClose} onSaved={onSaved} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onSaved, onClose };
|
||||
};
|
||||
|
||||
const save = (user: ReturnType<typeof setup>) => user.click(screen.getByRole("button", { name: "Save Credentials" }));
|
||||
|
||||
const fieldAfterOpen = async (label: RegExp): Promise<HTMLElement> => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string>) => 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<string, string> =>
|
||||
Object.fromEntries(required.map((spec) => [spec.name, ""]));
|
||||
|
||||
const UserEnvVarsForm: React.FC<UserEnvVarsFormProps> = ({ required, isSaving, onCancel, onSubmit }) => {
|
||||
const form = useZodForm(buildSchema(required), { defaultValues: emptyValues(required) });
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
{required.map((spec) => (
|
||||
<FormField
|
||||
key={spec.name}
|
||||
control={form.control}
|
||||
name={spec.name}
|
||||
description={spec.description || undefined}
|
||||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">{spec.name}</span>
|
||||
{spec.is_set && <Tag color="green">Set</Tag>}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(field) => (
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={isSaving}
|
||||
placeholder={
|
||||
spec.is_set ? "Enter a new value to overwrite" : spec.description || `Enter your ${spec.name}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
</FieldGroup>
|
||||
<div className="mt-6 flex items-center justify-end gap-2 border-t border-border pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <UiLoadingSpinner className="mr-2 size-4" />}
|
||||
Save Credentials
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<UserEnvVarsModalProps> = ({ 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<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
width={520}
|
||||
destroyOnHidden
|
||||
afterOpenChange={(opened) => {
|
||||
if (opened) form.resetFields();
|
||||
if (opened) setFormGeneration((generation) => generation + 1);
|
||||
}}
|
||||
title={
|
||||
<div>
|
||||
|
|
@ -95,42 +163,18 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
<Alert type="info" showIcon message="No per-user fields configured for this server." />
|
||||
) : (
|
||||
<>
|
||||
<Text className="text-sm text-gray-600 block">
|
||||
<Text className="text-sm text-muted-foreground block">
|
||||
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.
|
||||
</Text>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave} disabled={isSaving}>
|
||||
{required.map((spec) => (
|
||||
<Form.Item
|
||||
key={spec.name}
|
||||
name={spec.name}
|
||||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">{spec.name}</span>
|
||||
{spec.is_set && <Tag color="green">Set</Tag>}
|
||||
</span>
|
||||
}
|
||||
extra={spec.description || undefined}
|
||||
rules={spec.is_set ? undefined : [{ required: true, message: `${spec.name} is required` }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={
|
||||
spec.is_set ? "Enter a new value to overwrite" : spec.description || `Enter your ${spec.name}`
|
||||
}
|
||||
visibilityToggle
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-100">
|
||||
<Button onClick={onClose} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
Save Credentials
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<UserEnvVarsForm
|
||||
key={formGeneration}
|
||||
required={required}
|
||||
isSaving={isSaving}
|
||||
onCancel={onClose}
|
||||
onSubmit={handleSave}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<FeatureCardProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Card className="border border-gray-200">
|
||||
<Card className="border border-border">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="p-2 rounded-lg bg-gray-50">{icon}</span>
|
||||
<span className="p-2 rounded-lg bg-muted">{icon}</span>
|
||||
<div>
|
||||
<Title level={5} className="mb-0">
|
||||
{title}
|
||||
</Title>
|
||||
<Text className="text-gray-600">{description}</Text>
|
||||
<Text className="text-muted-foreground">{description}</Text>
|
||||
</div>
|
||||
</div>
|
||||
{serverName && (title === "Implementation Example" || title === "Configuration") && (
|
||||
<Form.Item className="mb-4">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch size="small" checked={useServerHeader} onChange={setUseServerHeader} />
|
||||
<Text className="text-sm">
|
||||
|
|
@ -81,14 +81,14 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
<p>
|
||||
<strong>Option 2:</strong> Get a group of MCPs: <code>"dev-group"</code>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
You can also mix both: <code>"Server1,dev-group"</code>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
{React.Children.map(children, (child) => {
|
||||
if (
|
||||
|
|
@ -137,13 +137,13 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<div className="relative group">
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Code size={16} className="text-blue-600" />
|
||||
<Text strong className="text-gray-700">
|
||||
<Code size={16} className="text-blue-600 dark:text-blue-400" />
|
||||
<Text strong className="text-foreground">
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<Card className={`bg-gray-50 border border-gray-200 relative ${className}`}>
|
||||
<Card className={`bg-muted border border-border relative ${className}`}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
|
|
@ -151,11 +151,11 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
onClick={() => copyToClipboard(code, copyKey)}
|
||||
className={`absolute top-2 right-2 z-10 transition-all duration-200 ${
|
||||
copiedStates[copyKey]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
? "text-green-600 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950 dark:border-green-800"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
/>
|
||||
<pre className="text-sm overflow-x-auto pr-10 text-gray-800 font-mono leading-relaxed">{code}</pre>
|
||||
<pre className="text-sm overflow-x-auto pr-10 text-foreground font-mono leading-relaxed">{code}</pre>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -167,12 +167,12 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
}> = ({ step, title, children }) => (
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold dark:bg-blue-500">
|
||||
{step}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Text strong className="text-gray-800 block mb-2">
|
||||
<Text strong className="text-foreground block mb-2">
|
||||
{title}
|
||||
</Text>
|
||||
{children}
|
||||
|
|
@ -254,21 +254,21 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
|
||||
const OpenAITab = () => (
|
||||
<Space direction="vertical" size="large" className="w-full">
|
||||
<div className="bg-linear-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-100">
|
||||
<div className="bg-linear-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-100 dark:from-blue-950 dark:to-indigo-950 dark:border-blue-900">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Code className="text-blue-600" size={24} />
|
||||
<Title level={4} className="mb-0 text-blue-900">
|
||||
<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
|
||||
</Title>
|
||||
</div>
|
||||
<Text className="text-blue-700">
|
||||
<Text className="text-blue-700 dark:text-blue-300">
|
||||
Connect OpenAI Responses API to your LiteLLM MCP server for seamless tool integration
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" size="large" className="w-full">
|
||||
<FeatureCard
|
||||
icon={<KeyIcon className="text-blue-600" size={16} />}
|
||||
icon={<KeyIcon className="text-blue-600 dark:text-blue-400" size={16} />}
|
||||
title="API Key Setup"
|
||||
description="Configure your OpenAI API key for authentication"
|
||||
>
|
||||
|
|
@ -281,7 +281,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ 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 <ExternalLinkIcon size={12} />
|
||||
</a>
|
||||
|
|
@ -292,7 +292,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
icon={<ServerIcon className="text-blue-600" size={16} />}
|
||||
icon={<ServerIcon className="text-blue-600 dark:text-blue-400" size={16} />}
|
||||
title="MCP Server Information"
|
||||
description="Connection details for your LiteLLM MCP server"
|
||||
>
|
||||
|
|
@ -300,7 +300,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Code className="text-blue-600" size={16} />}
|
||||
icon={<Code className="text-blue-600 dark:text-blue-400" size={16} />}
|
||||
title="Implementation Example"
|
||||
description="Complete cURL example for using the Responses API"
|
||||
serverName="Zapier Gmail"
|
||||
|
|
@ -350,27 +350,27 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
</Text>
|
||||
</div>
|
||||
|
||||
<Card className="border border-gray-200">
|
||||
<Title level={5} className="mb-4 text-gray-800">
|
||||
<Card className="border border-border">
|
||||
<Title level={5} className="mb-4 text-foreground">
|
||||
Setup Instructions
|
||||
</Title>
|
||||
<Space direction="vertical" size="large" className="w-full">
|
||||
<StepCard step={1} title="Open Cursor Settings">
|
||||
<Text className="text-gray-600">
|
||||
Use the keyboard shortcut <code className="bg-gray-100 px-2 py-1 rounded-sm">⇧+⌘+J</code> (Mac) or{" "}
|
||||
<code className="bg-gray-100 px-2 py-1 rounded-sm">Ctrl+Shift+J</code> (Windows/Linux)
|
||||
<Text className="text-muted-foreground">
|
||||
Use the keyboard shortcut <code className="bg-muted px-2 py-1 rounded-sm">⇧+⌘+J</code> (Mac) or{" "}
|
||||
<code className="bg-muted px-2 py-1 rounded-sm">Ctrl+Shift+J</code> (Windows/Linux)
|
||||
</Text>
|
||||
</StepCard>
|
||||
|
||||
<StepCard step={2} title="Navigate to MCP Tools">
|
||||
<Text className="text-gray-600">Go to the "MCP Tools" tab and click "New MCP Server"</Text>
|
||||
<Text className="text-muted-foreground">Go to the "MCP Tools" tab and click "New MCP Server"</Text>
|
||||
</StepCard>
|
||||
|
||||
<StepCard step={3} title="Add Configuration">
|
||||
<Text className="text-gray-600 mb-3">
|
||||
<Text className="text-muted-foreground mb-3">
|
||||
Copy the JSON configuration below and paste it into Cursor, then save with{" "}
|
||||
<code className="bg-gray-100 px-2 py-1 rounded-sm">Cmd+S</code> or{" "}
|
||||
<code className="bg-gray-100 px-2 py-1 rounded-sm">Ctrl+S</code>
|
||||
<code className="bg-muted px-2 py-1 rounded-sm">Cmd+S</code> or{" "}
|
||||
<code className="bg-muted px-2 py-1 rounded-sm">Ctrl+S</code>
|
||||
</Text>
|
||||
<FeatureCard
|
||||
icon={<Code className="text-purple-600" size={16} />}
|
||||
|
|
@ -405,18 +405,18 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<Space direction="vertical" size="large" className="w-full">
|
||||
<div className="bg-linear-to-r from-green-50 to-teal-50 p-6 rounded-lg border border-green-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Globe className="text-green-600" size={24} />
|
||||
<Title level={4} className="mb-0 text-green-900">
|
||||
<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
|
||||
</Title>
|
||||
</div>
|
||||
<Text className="text-green-700">
|
||||
<Text className="text-green-700 dark:text-green-300">
|
||||
Connect to LiteLLM MCP using HTTP transport. Compatible with any MCP client that supports HTTP streaming.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Globe className="text-green-600" size={16} />}
|
||||
icon={<Globe className="text-green-600 dark:text-green-400" size={16} />}
|
||||
title="Universal MCP Connection"
|
||||
description="Use this URL with any MCP client that supports HTTP transport"
|
||||
>
|
||||
|
|
@ -442,7 +442,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<div className="mt-4">
|
||||
<Button
|
||||
type="link"
|
||||
className="p-0 h-auto text-blue-600 hover:text-blue-700"
|
||||
className="p-0 h-auto text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
href="https://modelcontextprotocol.io/docs/concepts/transports"
|
||||
icon={<ExternalLinkIcon size={14} />}
|
||||
>
|
||||
|
|
@ -458,8 +458,8 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
<div>
|
||||
<Space direction="vertical" size="large" className="w-full">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-3">Connect to your MCP client</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">Connect to your MCP client</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Use tools directly from any MCP client with LiteLLM MCP. Enable your AI assistant to perform real-world
|
||||
tasks through a simple, secure connection.
|
||||
</p>
|
||||
|
|
@ -467,7 +467,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
|
||||
<Tabs defaultValue="openai" className="w-full">
|
||||
<TabsList variant="line" className="mt-8 mb-6 h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<div className="flex rounded-lg bg-gray-100 p-1">
|
||||
<div className="flex rounded-lg bg-muted p-1">
|
||||
<TabsTrigger value="openai" className="flex-none px-6 py-3">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Code size={18} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "./MCPToolArgumentsForm";
|
||||
import { MCPTool, InputSchema } from "./types";
|
||||
|
||||
const toolWith = (schema: InputSchema | string): MCPTool =>
|
||||
({ name: "demo_tool", description: "", inputSchema: schema, mcp_info: {} }) as unknown as MCPTool;
|
||||
|
||||
const renderForm = (schema: InputSchema | string) => {
|
||||
const ref = React.createRef<MCPToolArgumentsFormRef>();
|
||||
render(<MCPToolArgumentsForm ref={ref} tool={toolWith(schema)} />);
|
||||
return ref;
|
||||
};
|
||||
|
||||
const submit = async (ref: React.RefObject<MCPToolArgumentsFormRef | null>) => ref.current!.getSubmitValues();
|
||||
|
||||
const submitError = async (ref: React.RefObject<MCPToolArgumentsFormRef | null>) => {
|
||||
try {
|
||||
await ref.current!.getSubmitValues();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
describe("MCPToolArgumentsForm", () => {
|
||||
it("returns typed values for a string, integer, number and boolean field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ref = renderForm({
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
count: { type: "integer" },
|
||||
ratio: { type: "number" },
|
||||
verbose: { type: "boolean" },
|
||||
},
|
||||
required: [],
|
||||
});
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter city"), "berlin");
|
||||
await user.clear(screen.getByPlaceholderText("Enter count"));
|
||||
await user.type(screen.getByPlaceholderText("Enter count"), "7");
|
||||
await user.clear(screen.getByPlaceholderText("Enter ratio"));
|
||||
await user.type(screen.getByPlaceholderText("Enter ratio"), "1.5");
|
||||
|
||||
const expected = { city: "berlin", count: 7, ratio: 1.5, verbose: false };
|
||||
await expect(submit(ref)).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it("truncates a fractional value for an integer field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ref = renderForm({ type: "object", properties: { count: { type: "integer" } }, required: [] });
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("Enter count"));
|
||||
await user.type(screen.getByPlaceholderText("Enter count"), "9.8");
|
||||
|
||||
await expect(submit(ref)).resolves.toEqual({ count: 9 });
|
||||
});
|
||||
|
||||
it("drops an empty optional string rather than sending an empty value", async () => {
|
||||
const ref = renderForm({
|
||||
type: "object",
|
||||
properties: { city: { type: "string" }, country: { type: "string" } },
|
||||
required: [],
|
||||
});
|
||||
|
||||
await expect(submit(ref)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it("parses a JSON object field into an object", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ref = renderForm({ type: "object", properties: { filters: { type: "object" } }, required: [] });
|
||||
|
||||
const textarea = screen.getByPlaceholderText("Enter JSON object for filters");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, '{{"a": 1}');
|
||||
|
||||
await expect(submit(ref)).resolves.toEqual({ filters: { a: 1 } });
|
||||
});
|
||||
|
||||
it("parses a JSON array field into an array", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ref = renderForm({ type: "object", properties: { tags: { type: "array" } }, required: [] });
|
||||
|
||||
const textarea = screen.getByPlaceholderText("Enter JSON array for tags");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, '[["x","y"]');
|
||||
|
||||
await expect(submit(ref)).resolves.toEqual({ tags: ["x", "y"] });
|
||||
});
|
||||
|
||||
it("rejects and reports invalid JSON for an object field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ref = renderForm({ type: "object", properties: { filters: { type: "object" } }, required: [] });
|
||||
|
||||
const textarea = screen.getByPlaceholderText("Enter JSON object for filters");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "not json");
|
||||
|
||||
expect(await submitError(ref)).not.toBeNull();
|
||||
expect(await screen.findByText("Invalid JSON")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rejects a JSON array typed into an object field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ref = renderForm({ type: "object", properties: { filters: { type: "object" } }, required: [] });
|
||||
|
||||
const textarea = screen.getByPlaceholderText("Enter JSON object for filters");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "[[1,2]");
|
||||
|
||||
expect(await submitError(ref)).not.toBeNull();
|
||||
expect(await screen.findByText("Please enter a JSON object")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rejects an empty required field with the per-field message", async () => {
|
||||
const ref = renderForm({ type: "object", properties: { city: { type: "string" } }, required: ["city"] });
|
||||
|
||||
expect(await submitError(ref)).not.toBeNull();
|
||||
expect(await screen.findByText("Please enter city")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rejects with a non-Error carrying errorFields, which is what the caller branches on", async () => {
|
||||
const ref = renderForm({ type: "object", properties: { city: { type: "string" } }, required: ["city"] });
|
||||
|
||||
const error = await submitError(ref);
|
||||
expect(error).not.toBeInstanceOf(Error);
|
||||
expect(error).toMatchObject({ errorFields: [{ name: ["city"], errors: ["Please enter city"] }] });
|
||||
});
|
||||
|
||||
it("wraps values under params when the schema nests them", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ref = renderForm({
|
||||
type: "object",
|
||||
properties: {
|
||||
params: { type: "object", properties: { city: { type: "string" } }, required: [] },
|
||||
},
|
||||
required: [],
|
||||
});
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter city"), "oslo");
|
||||
|
||||
await expect(submit(ref)).resolves.toEqual({ params: { city: "oslo" } });
|
||||
});
|
||||
|
||||
it("renders a single input field when the schema is only a string", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ref = renderForm("tool_input_schema");
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Enter input for this tool"), "hello");
|
||||
|
||||
await expect(submit(ref)).resolves.toEqual({ input: "hello" });
|
||||
});
|
||||
|
||||
it("reports the required message for the string-schema input", async () => {
|
||||
const ref = renderForm("tool_input_schema");
|
||||
|
||||
expect(await submitError(ref)).not.toBeNull();
|
||||
expect(await screen.findByText("Please enter input for this tool")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("seeds a schema default and sends it untouched", async () => {
|
||||
const ref = renderForm({
|
||||
type: "object",
|
||||
properties: { city: { type: "string", default: "paris" }, count: { type: "integer", default: 3 } },
|
||||
required: [],
|
||||
});
|
||||
|
||||
await expect(submit(ref)).resolves.toEqual({ city: "paris", count: 3 });
|
||||
});
|
||||
|
||||
it("shows the empty state and submits nothing when the schema has no properties", async () => {
|
||||
const ref = renderForm({ type: "object" } as InputSchema);
|
||||
|
||||
expect(screen.getByText("No parameters required for this tool.")).toBeInTheDocument();
|
||||
await expect(submit(ref)).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,73 @@
|
|||
import React, { forwardRef, useImperativeHandle, useMemo } from "react";
|
||||
import { Form, Input, InputNumber, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Select } from "antd";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { MCPTool, InputSchema, InputSchemaProperty } from "./types";
|
||||
|
||||
type ToolFormValues = Record<string, unknown>;
|
||||
|
||||
const STRING_SCHEMA_MESSAGES: Readonly<Record<string, string>> = { input: "Please enter input for this tool" };
|
||||
|
||||
const isBlank = (value: unknown): boolean => value === undefined || value === null || value === "";
|
||||
|
||||
const jsonErrorFor = (prop: InputSchemaProperty, 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);
|
||||
if (isValidObject || isValidArray) return null;
|
||||
return prop.type === "object" ? "Please enter a JSON object" : "Please enter a JSON array";
|
||||
} catch {
|
||||
return "Invalid JSON";
|
||||
}
|
||||
};
|
||||
|
||||
type FieldError = { type: string; message: string };
|
||||
|
||||
const collectErrors = (
|
||||
actualSchema: InputSchema,
|
||||
requiredMessages: Readonly<Record<string, string>>,
|
||||
values: ToolFormValues,
|
||||
): Record<string, FieldError> => {
|
||||
const entries = Object.entries(actualSchema.properties ?? {}).flatMap<[string, FieldError]>(([key, prop]) => {
|
||||
const value = values[key];
|
||||
const blank = isBlank(value);
|
||||
if (actualSchema.required?.includes(key) && blank) {
|
||||
return [[key, { type: "required", message: requiredMessages[key] ?? `Please enter ${key}` }]];
|
||||
}
|
||||
if (prop.type !== "object" && prop.type !== "array") return [];
|
||||
if (blank) return [];
|
||||
const message = jsonErrorFor(prop, value);
|
||||
return message === null ? [] : [[key, { type: "validate", message }]];
|
||||
});
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
const buildResolver =
|
||||
(actualSchema: InputSchema, requiredMessages: Readonly<Record<string, string>> = {}): Resolver<ToolFormValues> =>
|
||||
(values) => {
|
||||
const errors = collectErrors(actualSchema, requiredMessages, values);
|
||||
return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} };
|
||||
};
|
||||
|
||||
const labelFor = (key: string, prop: InputSchemaProperty, required: boolean): React.ReactNode => (
|
||||
<span className="flex items-center">
|
||||
{key} {required && <span className="text-destructive">*</span>}
|
||||
{prop.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="ml-2 size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{prop.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
|
|
@ -140,8 +205,6 @@ interface MCPToolArgumentsFormProps {
|
|||
|
||||
const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgumentsFormProps>(
|
||||
({ tool, className }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const schema: InputSchema = useMemo(() => {
|
||||
if (typeof tool.inputSchema === "string") {
|
||||
return {
|
||||
|
|
@ -169,151 +232,168 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
|
|||
return schema;
|
||||
}, [schema]);
|
||||
|
||||
const defaultValues = useMemo<ToolFormValues>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(actualSchema.properties ?? {}).map(([key, prop]) => [key, getInitialValueForField(prop)]),
|
||||
),
|
||||
[actualSchema],
|
||||
);
|
||||
|
||||
const isStringSchema = typeof tool.inputSchema === "string";
|
||||
const requiredMessages = isStringSchema ? STRING_SCHEMA_MESSAGES : {};
|
||||
const form = useForm<ToolFormValues>({
|
||||
defaultValues,
|
||||
resolver: buildResolver(actualSchema, requiredMessages),
|
||||
});
|
||||
const { reset } = form;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getSubmitValues: async () => {
|
||||
const values = await form.validateFields();
|
||||
const values = form.getValues();
|
||||
const errors = collectErrors(actualSchema, requiredMessages, values);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
await form.trigger();
|
||||
return Promise.reject({
|
||||
errorFields: Object.entries(errors).map(([name, error]) => ({
|
||||
name: [name],
|
||||
errors: [error.message],
|
||||
})),
|
||||
});
|
||||
}
|
||||
return convertFormValues(values, actualSchema, schema);
|
||||
},
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
form.resetFields();
|
||||
if (!actualSchema.properties) return;
|
||||
reset(defaultValues);
|
||||
}, [reset, defaultValues, tool]);
|
||||
|
||||
const initialValues: Record<string, any> = {};
|
||||
Object.entries(actualSchema.properties).forEach(([key, prop]) => {
|
||||
initialValues[key] = getInitialValueForField(prop);
|
||||
});
|
||||
form.setFieldsValue(initialValues);
|
||||
}, [form, actualSchema, tool]);
|
||||
|
||||
if (typeof tool.inputSchema === "string") {
|
||||
if (isStringSchema) {
|
||||
return (
|
||||
<Form form={form} layout="vertical" className={className}>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Input <span className="text-red-500">*</span>
|
||||
</span>
|
||||
}
|
||||
name="input"
|
||||
rules={[{ required: true, message: "Please enter input for this tool" }]}
|
||||
>
|
||||
<Input placeholder="Enter input for this tool" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.trigger();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="input"
|
||||
label={
|
||||
<span>
|
||||
Input <span className="text-destructive">*</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(field) => <Input {...field} value={field.value as string} placeholder="Enter input for this tool" />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (!actualSchema.properties) {
|
||||
return (
|
||||
<Form form={form} layout="vertical" className={className}>
|
||||
<div className="py-4 text-center text-sm text-gray-500">No parameters required for this tool.</div>
|
||||
</Form>
|
||||
<form onSubmit={(event) => event.preventDefault()} className={className}>
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">No parameters required for this tool.</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical" className={className}>
|
||||
{Object.entries(actualSchema.properties).map(([key, prop]) => {
|
||||
const initialValue = getInitialValueForField(prop);
|
||||
const fieldKey = `${tool.name}-${key}`;
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldKey}
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
{key} {actualSchema.required?.includes(key) && <span className="text-red-500">*</span>}
|
||||
{prop.description && (
|
||||
<Tooltip title={prop.description}>
|
||||
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
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();
|
||||
<TooltipProvider>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.trigger();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<FieldGroup>
|
||||
{Object.entries(actualSchema.properties).map(([key, prop]) => {
|
||||
const required = actualSchema.required?.includes(key) ?? false;
|
||||
return (
|
||||
<FormField
|
||||
key={`${tool.name}-${key}`}
|
||||
control={form.control}
|
||||
name={key}
|
||||
label={labelFor(key, prop, required)}
|
||||
>
|
||||
{(field) => {
|
||||
if (prop.type === "string" && prop.enum) {
|
||||
return (
|
||||
<Select
|
||||
id={field.id}
|
||||
value={field.value as string | undefined}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder={`Select ${key}`}
|
||||
allowClear={!required}
|
||||
options={prop.enum.map((v) => ({ value: v, label: v }))}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prop.type === "boolean") {
|
||||
return (
|
||||
<Select
|
||||
id={field.id}
|
||||
value={field.value as boolean | undefined}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder={`Select ${key}`}
|
||||
allowClear={!required}
|
||||
options={[
|
||||
{ value: true, label: "True" },
|
||||
{ value: false, label: "False" },
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prop.type === "number" || prop.type === "integer") {
|
||||
return (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
step={prop.type === "integer" ? 1 : undefined}
|
||||
value={field.value as number | string}
|
||||
placeholder={prop.description || `Enter ${key}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prop.type === "object" || prop.type === "array") {
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
rows={prop.type === "object" ? 4 : 3}
|
||||
value={field.value as string}
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
placeholder={
|
||||
prop.description ||
|
||||
(prop.type === "object" ? `Enter JSON object for ${key}` : `Enter JSON array for ${key}`)
|
||||
}
|
||||
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 {
|
||||
return Promise.reject(new Error("Invalid JSON"));
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
{prop.type === "string" && prop.enum ? (
|
||||
<Select
|
||||
placeholder={`Select ${key}`}
|
||||
allowClear={!actualSchema.required?.includes(key)}
|
||||
options={prop.enum.map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
) : prop.type === "string" && !prop.enum ? (
|
||||
<Input placeholder={prop.description || `Enter ${key}`} allowClear />
|
||||
) : prop.type === "number" || prop.type === "integer" ? (
|
||||
<InputNumber
|
||||
step={prop.type === "integer" ? 1 : undefined}
|
||||
placeholder={prop.description || `Enter ${key}`}
|
||||
className="w-full"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
) : prop.type === "boolean" ? (
|
||||
<Select
|
||||
placeholder={`Select ${key}`}
|
||||
allowClear={!actualSchema.required?.includes(key)}
|
||||
options={[
|
||||
{ value: true, label: "True" },
|
||||
{ value: false, label: "False" },
|
||||
]}
|
||||
/>
|
||||
) : prop.type === "object" || prop.type === "array" ? (
|
||||
<Input.TextArea
|
||||
rows={prop.type === "object" ? 4 : 3}
|
||||
placeholder={
|
||||
prop.description ||
|
||||
(prop.type === "object" ? `Enter JSON object for ${key}` : `Enter JSON array for ${key}`)
|
||||
}
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={prop.description || `Enter ${key}`} allowClear />
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
placeholder={prop.description || `Enter ${key}`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
);
|
||||
})}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue