feat(ui): migrate mcp_tools configuration forms to shadcn

- Migrate src/components/mcp_tools/mcp_tool_configuration.tsx from
  Tremor Card/Title/Text + antd Badge/Spin/Checkbox/Input/Radio to shadcn
  Card/Input/Checkbox/Badge/ToggleGroup + lucide icons. Replaces the
  Radio.Group view-mode toggle with a shadcn ToggleGroup, and the
  antd Spin with a spinning lucide icon. Raw Tailwind colors swapped for
  semantic tokens (text-muted-foreground, bg-primary/5, border-primary,
  text-destructive).
- Migrate src/components/mcp_tools/MCPToolArgumentsForm.tsx from antd
  Form.useForm + Form.Item + antd Input/InputNumber/Select/Tooltip to
  react-hook-form with useForm + Controller and shadcn Input/Textarea/
  Select/Label/Tooltip. Ref-exposed getSubmitValues() now triggers RHF
  validation via trigger() + getValues() and still returns the typed,
  JSON-normalised payload expected by playground/chat_ui.
- Record create_mcp_server.tsx / mcp_server_edit.tsx / OAuthFormFields.tsx /
  StdioConfiguration.tsx / OpenAPIFormSection.tsx / MCPPermissionManagement.tsx
  in BLOCKERS.md. These form a single antd Form cluster (parent +
  Form.Item-based children registering into the parent's FormInstance)
  whose migration, including rewriting the antd-class-based test helpers
  in create_mcp_server.test.tsx / mcp_server_edit.test.tsx, exceeds the
  two-attempt budget for this batch.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
cursor 2026-04-24 09:37:52 +00:00
parent a2f8c87e9f
commit a6e177e123
No known key found for this signature in database
3 changed files with 465 additions and 327 deletions

View file

@ -69,3 +69,46 @@ stay on antd for phase 1 and will be addressed in a targeted follow-up.
a dict-field builder with dynamic antd `Select.Option` lists. Same
dependency on the parent antd Form context as the above. Defer with the
parent forms.
- `src/components/mcp_tools/create_mcp_server.tsx` (1060 LoC): end-to-end
antd `Form` with deeply nested field paths (`["credentials", "client_id"]`,
`static_headers` list with inline validators), `shouldUpdate` driven
conditional rendering, `Form.useWatch`-style derived state, antd `Modal`,
antd `Collapse` auth panel, antd `Select mode="tags"` for access groups.
Existing `create_mcp_server.test.tsx` has ~15 tests keyed to antd class
selectors (`.ant-select`, `.ant-select-selector`, `.ant-select-item-option`,
`.ant-collapse-item`, `.ant-form-item`) and a full dropdown-click helper
(`selectAntOption`) that has no stable shadcn analog in jsdom (Radix
`Select` requires hasPointerCapture + scrollIntoView polyfills). Migration
requires rewriting every test + migrating all 4 child form sections
(`OAuthFormFields`, `StdioConfiguration`, `OpenAPIFormSection`,
`MCPPermissionManagement`) in the same commit. Exceeds 2-attempt budget.
- `src/components/mcp_tools/mcp_server_edit.tsx` (1151 LoC): mirrors
create_mcp_server's antd Form structure for the edit flow, plus Tremor
`TabGroup`/`TabList`/`TabPanels`. Uses `Form.useWatch` for derived state
(authType, transportType, oauth_flow_type) that drives conditional
rendering. Shares the same child form components as create; cannot be
migrated independently. Existing `mcp_server_edit.test.tsx` has 7 tests
keyed to antd form id lookups (`document.getElementById("token_validation_json")`)
and `Form.Item`-generated labels. Defer with create_mcp_server.
- `src/components/mcp_tools/OAuthFormFields.tsx` (327 LoC): renders antd
`Form.Item` elements (including `name={["credentials", "client_id"]}`,
`Select mode="tags"`, inline `validator` rules for JSON parse, `InputNumber`)
keyed to the parent's antd Form context. Cannot be migrated independently
of its two callers (`create_mcp_server.tsx`, `mcp_server_edit.tsx`).
`OAuthFormFields.test.tsx` has 13 tests directly importing `Form` from
antd and asserting on antd-validation error messages. Defer with parents.
- `src/components/mcp_tools/StdioConfiguration.tsx`: renders a single antd
`Form.Item` with `name="stdio_config"` and inline JSON-parse `validator`,
registering into the parent antd Form context. Cannot be migrated
independently. Defer with parents.
- `src/components/mcp_tools/OpenAPIFormSection.tsx`: renders an antd
`Form.Item` with `name="spec_path"` and takes an antd `FormInstance`
prop for `setFieldsValue`/`resetFields`. Tightly coupled to the parent
antd Form. Defer with parents.
- `src/components/mcp_tools/MCPPermissionManagement.tsx`: uses
`Form.useFormInstance()` + `Form.List` for dynamic static-headers plus
several `Form.Item` fields (`allow_all_keys`, `available_on_public_internet`,
`mcp_access_groups`, `extra_headers`). Directly writes to the parent's
antd Form via `form.setFieldValue`. `MCPPermissionManagement.test.tsx`
wraps it in an antd `<Form form={form}>` harness. Cannot migrate
independently of the parent forms. Defer with parents.

View file

@ -1,5 +1,21 @@
import React, { forwardRef, useImperativeHandle, useMemo } from "react";
import { Form, Input, InputNumber, Select, Tooltip } from "antd";
import React, { forwardRef, useEffect, useImperativeHandle, useMemo } from "react";
import { useForm, Controller } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Info } from "lucide-react";
import { MCPTool, InputSchema, InputSchemaProperty } from "./types";
@ -144,10 +160,40 @@ interface MCPToolArgumentsFormProps {
className?: string;
}
function validateField(
key: string,
prop: InputSchemaProperty,
required: boolean,
value: any,
): string | true {
if (required && (value === undefined || value === null || value === "")) {
return `Please enter ${key}`;
}
if (prop.type === "object" || prop.type === "array") {
if ((value === undefined || value === null || value === "") && !required) {
return true;
}
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 true;
}
return prop.type === "object" ? "Please enter a JSON object" : "Please enter a JSON array";
} catch {
return "Invalid JSON";
}
}
return true;
}
const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgumentsFormProps>(
({ tool, className }, ref) => {
const [form] = Form.useForm();
const schema: InputSchema = useMemo(() => {
if (typeof tool.inputSchema === "string") {
return {
@ -178,164 +224,234 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
return schema;
}, [schema]);
const defaultValues = useMemo(() => {
const values: Record<string, any> = {};
if (actualSchema.properties) {
Object.entries(actualSchema.properties).forEach(([key, prop]) => {
values[key] = getInitialValueForField(prop);
});
}
return values;
}, [actualSchema]);
const form = useForm<Record<string, any>>({
defaultValues,
mode: "onSubmit",
});
const { control, handleSubmit, reset, trigger, getValues, formState } = form;
useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset, tool]);
useImperativeHandle(ref, () => ({
getSubmitValues: async () => {
const values = await form.validateFields();
const valid = await trigger();
if (!valid) {
throw new Error("Validation failed");
}
const values = getValues();
return convertFormValues(values, actualSchema, schema);
},
}));
React.useEffect(() => {
form.resetFields();
if (!actualSchema.properties) return;
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") {
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>
<form className={className} onSubmit={handleSubmit(() => {})}>
<div className="space-y-2">
<Label htmlFor={`${tool.name}-input`}>
<span className="text-sm font-medium text-foreground">
Input <span className="text-destructive">*</span>
</span>
}
name="input"
rules={[{ required: true, message: "Please enter input for this tool" }]}
>
<Input placeholder="Enter input for this tool" />
</Form.Item>
</Form>
</Label>
<Controller
control={control}
name="input"
rules={{ required: "Please enter input for this tool" }}
render={({ field, fieldState }) => (
<>
<Input
id={`${tool.name}-input`}
placeholder="Enter input for this tool"
{...field}
value={field.value ?? ""}
/>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</>
)}
/>
</div>
</form>
);
}
if (!actualSchema.properties) {
return (
<Form form={form} layout="vertical" className={className}>
<div className="py-4 text-center text-sm text-gray-500">
<form className={className} onSubmit={handleSubmit(() => {})}>
<div className="py-4 text-center text-sm text-muted-foreground">
No parameters required for this tool.
</div>
</Form>
</form>
);
}
return (
<Form form={form} layout="vertical" className={className}>
<form className={className} onSubmit={handleSubmit(() => {})}>
{Object.entries(actualSchema.properties).map(([key, prop]) => {
const initialValue = getInitialValueForField(prop);
const required = !!actualSchema.required?.includes(key);
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>}
<div key={fieldKey} className="space-y-2">
<Label htmlFor={fieldKey}>
<span className="text-sm font-medium text-foreground flex items-center">
{key} {required && <span className="text-destructive">*</span>}
{prop.description && (
<Tooltip title={prop.description}>
<Info className="ml-2 h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="ml-2 h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">{prop.description}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</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();
}
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}`)
</Label>
<Controller
control={control}
name={key}
rules={{
validate: (value) => validateField(key, prop, required, value),
}}
render={({ field, fieldState }) => {
const errorMessage = fieldState.error?.message;
if (prop.type === "string" && prop.enum) {
return (
<>
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(v)}
>
<SelectTrigger id={fieldKey}>
<SelectValue placeholder={`Select ${key}`} />
</SelectTrigger>
<SelectContent>
{prop.enum!.map((v) => (
<SelectItem key={String(v)} value={String(v)}>
{String(v)}
</SelectItem>
))}
</SelectContent>
</Select>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
spellCheck={false}
className="font-mono"
/>
) : (
<Input
placeholder={prop.description || `Enter ${key}`}
allowClear
/>
)}
</Form.Item>
if (prop.type === "string") {
return (
<>
<Input
id={fieldKey}
placeholder={prop.description || `Enter ${key}`}
{...field}
value={field.value ?? ""}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
if (prop.type === "number" || prop.type === "integer") {
return (
<>
<Input
id={fieldKey}
type="number"
step={prop.type === "integer" ? 1 : "any"}
placeholder={prop.description || `Enter ${key}`}
value={field.value ?? ""}
onChange={(e) => {
const v = e.target.value;
field.onChange(v === "" ? "" : Number(v));
}}
onBlur={field.onBlur}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
if (prop.type === "boolean") {
const valueStr =
field.value === true ? "true" : field.value === false ? "false" : "";
return (
<>
<Select
value={valueStr}
onValueChange={(v) => field.onChange(v === "true")}
>
<SelectTrigger id={fieldKey}>
<SelectValue placeholder={`Select ${key}`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
if (prop.type === "object" || prop.type === "array") {
return (
<>
<Textarea
id={fieldKey}
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"
{...field}
value={field.value ?? ""}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
return (
<>
<Input
id={fieldKey}
placeholder={prop.description || `Enter ${key}`}
{...field}
value={field.value ?? ""}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}}
/>
</div>
);
})}
</Form>
</form>
);
},
);

View file

@ -1,8 +1,17 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Card, Title, Text } from "@tremor/react";
import { Wrench as ToolOutlined, CheckCircle2 as CheckCircleOutlined, Search as SearchOutlined, Pencil as EditOutlined } from "lucide-react";
import { Badge, Spin, Checkbox, Input, Radio } from "antd";
import {
Wrench as ToolOutlined,
CheckCircle2 as CheckCircleOutlined,
Search as SearchOutlined,
Pencil as EditOutlined,
LoaderCircle,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTestMCPConnection } from "../../hooks/useTestMCPConnection";
import McpCrudPermissionPanel from "./McpCrudPermissionPanel";
@ -62,81 +71,83 @@ const ToolRow: React.FC<ToolRowProps> = ({
<div
className={`rounded-lg border transition-colors ${
isEnabled
? "bg-blue-50 border-blue-300 hover:border-blue-400"
: "bg-gray-50 border-gray-200 hover:border-gray-300"
? "bg-primary/5 border-primary/40 hover:border-primary/60"
: "bg-muted border-border hover:border-muted-foreground/40"
}`}
>
<div className="p-4 cursor-pointer" onClick={() => onToggle(tool.name)}>
<div className="flex items-start gap-3">
<Checkbox checked={isEnabled} onChange={() => onToggle(tool.name)} />
<Checkbox
checked={isEnabled}
onCheckedChange={() => onToggle(tool.name)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">
<span className="font-medium text-foreground">
{toolNameToDisplayName[tool.name] || tool.name}
</Text>
<span
className={`px-2 py-0.5 text-xs rounded-full font-medium ${
isEnabled ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{isEnabled ? "Enabled" : "Disabled"}
</span>
<Badge variant={isEnabled ? "secondary" : "outline"} className="text-xs">
{isEnabled ? "Enabled" : "Disabled"}
</Badge>
{toolNameToDisplayName[tool.name] && (
<span className="px-2 py-0.5 text-xs rounded-full font-medium bg-purple-100 text-purple-800">
<Badge variant="outline" className="text-xs">
Custom name
</span>
</Badge>
)}
</div>
{(toolNameToDescription[tool.name] || tool.description) && (
<Text className="text-gray-500 text-sm block mt-1">
<span className="text-muted-foreground text-sm block mt-1">
{toolNameToDescription[tool.name] || tool.description}
</Text>
</span>
)}
<Text className="text-gray-400 text-xs block mt-1">
<span className="text-muted-foreground text-xs block mt-1">
{isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"}
</Text>
</span>
</div>
<button
type="button"
onClick={(e) => onToggleExpand(tool.name, e)}
className={`p-1.5 rounded-md transition-colors ${
isEditExpanded
? "bg-blue-100 text-blue-600"
: "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
title="Edit display name and description"
>
<EditOutlined />
<EditOutlined className="h-4 w-4" />
</button>
</div>
</div>
{isEditExpanded && (
<div
className="px-4 pb-4 pt-3 border-t border-gray-200 space-y-3 bg-gray-50 rounded-b-lg"
className="px-4 pb-4 pt-3 border-t border-border space-y-3 bg-muted rounded-b-lg"
onClick={(e) => e.stopPropagation()}
>
<div>
<Text className="text-xs font-medium text-gray-600 mb-1 block">Display Name</Text>
<span className="text-xs font-medium text-muted-foreground mb-1 block">
Display Name
</span>
<Input
placeholder={tool.name}
value={toolNameToDisplayName[tool.name] || ""}
onChange={(e) => onDisplayNameChange(tool.name, e.target.value)}
/>
<Text className="text-xs text-gray-400 mt-1 block">
<span className="text-xs text-muted-foreground mt-1 block">
Override how this tool&apos;s name appears to users. Leave blank to use original.
</Text>
</span>
</div>
<div>
<Text className="text-xs font-medium text-gray-600 mb-1 block">Description</Text>
<Input.TextArea
<span className="text-xs font-medium text-muted-foreground mb-1 block">Description</span>
<Textarea
placeholder={tool.description || "No description"}
value={toolNameToDescription[tool.name] || ""}
onChange={(e) => onDescriptionChange(tool.name, e.target.value)}
rows={2}
/>
<Text className="text-xs text-gray-400 mt-1 block">
<span className="text-xs text-muted-foreground mt-1 block">
Override the tool description shown to users. Leave blank to use original.
</Text>
</span>
</div>
</div>
)}
@ -167,8 +178,6 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
const previousSuggestedToolNamesRef = useRef<string>("");
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
// Use external tool state when provided (avoids duplicate fetch with MCPConnectionStatus).
// Fall back to internal hook when used standalone (e.g., edit flow).
const hasExternalState = externalTools !== undefined;
const internalHook = useTestMCPConnection({
accessToken,
@ -181,7 +190,6 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
const toolsError = hasExternalState ? (externalError ?? null) : internalHook.toolsError;
const canFetchTools = hasExternalState ? (externalCanFetch ?? false) : internalHook.canFetchTools;
// Fuzzy-match curated key tool names against actual loaded tool names
const suggestedTools = useMemo(() => {
if (!keyTools || keyTools.length === 0 || tools.length === 0) return [];
const usedNames = new Set<string>();
@ -215,7 +223,6 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
[suggestedTools]
);
// Filter tools based on search term
const filteredTools = useMemo(
() =>
tools.filter((tool) => {
@ -238,13 +245,11 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
[filteredTools, suggestedToolNames]
);
// Auto-select tools when tools are first loaded or when tools list changes
useEffect(() => {
const currentToolNames = tools.map((tool) => tool.name).sort().join(",");
const previousToolNames = previousToolsRef.current.map((tool) => tool.name).sort().join(",");
const toolsListChanged = currentToolNames !== previousToolNames;
// Reset initialization when a new preset is selected (suggestedTools fingerprint changes)
const currentSuggestedNames = suggestedTools.map((t) => t.name).sort().join(",");
if (currentSuggestedNames !== previousSuggestedToolNamesRef.current) {
previousSuggestedToolNamesRef.current = currentSuggestedNames;
@ -260,22 +265,18 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
hasInitializedRef.current = true;
if (existingAllowedTools && existingAllowedTools.length > 0) {
// Edit mode: pre-select tools that match existing allowed tools
const validExistingTools = existingAllowedTools.filter((toolName) =>
availableToolNames.includes(toolName)
);
onAllowedToolsChange(validExistingTools);
} else if (suggestedTools.length > 0) {
// OpenAPI preset: only enable suggested tools by default
onAllowedToolsChange(
suggestedTools.map((t) => t.name).filter((name) => availableToolNames.includes(name))
);
} else {
// Create mode: auto-select all tools
onAllowedToolsChange(availableToolNames);
}
} else {
// Tools list changed after initial load (e.g., URL was edited)
const matchingTools = allowedTools.filter((toolName) => availableToolNames.includes(toolName));
onAllowedToolsChange(matchingTools);
}
@ -326,144 +327,123 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
};
const handleEnableSuggested = () => {
// Enable ALL suggested tools (not just the currently filtered subset)
const suggestedNames = suggestedTools.map((t) => t.name);
const others = allowedTools.filter((n) => !suggestedToolNames.has(n));
onAllowedToolsChange([...others, ...suggestedNames]);
};
const handleDisableSuggested = () => {
// Disable ALL suggested tools (not just the currently filtered subset)
onAllowedToolsChange(allowedTools.filter((n) => !suggestedToolNames.has(n)));
};
const handleEnableRest = () => {
// Enable ALL non-suggested tools (not just the currently filtered subset)
const restNames = tools.filter((t) => !suggestedToolNames.has(t.name)).map((t) => t.name);
const current = new Set(allowedTools);
onAllowedToolsChange([...allowedTools, ...restNames.filter((n) => !current.has(n))]);
};
const handleDisableRest = () => {
// Disable ALL non-suggested tools (not just the currently filtered subset)
onAllowedToolsChange(allowedTools.filter((n) => suggestedToolNames.has(n)));
};
// Don't show anything if required fields aren't filled
if (!canFetchTools && !formValues.url && !formValues.spec_path) {
return null;
}
return (
<Card>
<Card className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ToolOutlined className="text-blue-600" />
<Title>Tool Configuration</Title>
<ToolOutlined className="text-primary h-5 w-5" />
<h3 className="text-lg font-semibold">Tool Configuration</h3>
{tools.length > 0 && (
<Badge
count={tools.length}
style={{
backgroundColor: "#52c41a",
}}
/>
<Badge variant="secondary">{tools.length}</Badge>
)}
</div>
{tools.length > 0 && (
<Radio.Group
<ToggleGroup
type="single"
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
size="small"
optionType="button"
buttonStyle="solid"
options={[
{ label: "Risk Groups", value: "crud" },
{ label: "Flat List", value: "flat" },
]}
/>
onValueChange={(v) => {
if (v) setViewMode(v as "crud" | "flat");
}}
size="sm"
>
<ToggleGroupItem value="crud">Risk Groups</ToggleGroupItem>
<ToggleGroupItem value="flat">Flat List</ToggleGroupItem>
</ToggleGroup>
)}
</div>
{/* Description */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-blue-800 text-sm">
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3">
<span className="text-foreground text-sm">
<strong>Select which tools users can call:</strong> Only checked tools will be available for users to
invoke. Unchecked tools will be blocked from execution.
</Text>
</span>
</div>
{/* Loading state */}
{isLoadingTools && (
<div className="flex items-center justify-center py-6">
<Spin size="large" />
<Text className="ml-3">Loading tools from spec...</Text>
<LoaderCircle className="h-6 w-6 animate-spin text-primary" />
<span className="ml-3">Loading tools from spec...</span>
</div>
)}
{/* Error state */}
{toolsError && !isLoadingTools && (
<div className="text-center py-6 text-red-500 border rounded-lg border-dashed border-red-300 bg-red-50">
<ToolOutlined className="text-2xl mb-2" />
<Text className="text-red-600 font-medium">Unable to load tools</Text>
<br />
<Text className="text-sm text-red-500">{toolsError}</Text>
<div className="text-center py-6 text-destructive border rounded-lg border-dashed border-destructive/50 bg-destructive/5">
<ToolOutlined className="text-2xl mb-2 mx-auto h-6 w-6" />
<span className="text-destructive font-medium block">Unable to load tools</span>
<span className="text-sm text-destructive block">{toolsError}</span>
</div>
)}
{/* No tools state */}
{!isLoadingTools && !toolsError && tools.length === 0 && canFetchTools && (
keyTools && keyTools.length > 0 ? (
<div className="text-center py-4 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>No tools loaded from spec</Text>
<Text className="text-sm block mt-1">
<div className="text-center py-4 text-muted-foreground border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2 mx-auto h-6 w-6" />
<span>No tools loaded from spec</span>
<span className="text-sm block mt-1">
Expected tools: {keyTools.map((t) => t.name).join(", ")}
</Text>
</span>
</div>
) : (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>No tools available for configuration</Text>
<br />
<Text className="text-sm">Connect to an MCP server with tools to configure them</Text>
<div className="text-center py-6 text-muted-foreground border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2 mx-auto h-6 w-6" />
<span className="block">No tools available for configuration</span>
<span className="text-sm">Connect to an MCP server with tools to configure them</span>
</div>
)
)}
{/* Incomplete form state */}
{!canFetchTools && (formValues.url || formValues.spec_path) && (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>Complete required fields to configure tools</Text>
<br />
<Text className="text-sm">Fill in URL, Transport, and Authentication to load available tools</Text>
<div className="text-center py-6 text-muted-foreground border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2 mx-auto h-6 w-6" />
<span className="block">Complete required fields to configure tools</span>
<span className="text-sm">Fill in URL, Transport, and Authentication to load available tools</span>
</div>
)}
{/* Tools loaded successfully */}
{!isLoadingTools && !toolsError && tools.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200">
<CheckCircleOutlined className="text-green-600" />
<Text className="text-green-700 font-medium">
<div className="flex items-center gap-2 p-3 bg-primary/5 rounded-lg border border-primary/20">
<CheckCircleOutlined className="text-primary h-5 w-5" />
<span className="text-foreground font-medium">
{allowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} enabled for user
access
</Text>
</span>
</div>
{/* Search box shared by both views */}
<Input
placeholder="Search tools by name or description..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="large"
/>
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tools by name or description..."
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
{/* CRUD grouped view */}
{viewMode === "crud" && (
<McpCrudPermissionPanel
tools={tools}
@ -473,94 +453,93 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
/>
)}
{/* Flat list view */}
{viewMode === "flat" && (
<>
{filteredTools.length === 0 ? (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<SearchOutlined className="text-2xl mb-2" />
<Text>No tools found matching &quot;{toolSearchTerm}&quot;</Text>
<div className="text-center py-6 text-muted-foreground border rounded-lg border-dashed">
<SearchOutlined className="text-2xl mb-2 mx-auto h-6 w-6" />
<span>No tools found matching &quot;{toolSearchTerm}&quot;</span>
</div>
) : (
<div className="space-y-2">
{pinnedFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Suggested tools
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableSuggested}
className="text-xs text-blue-600 hover:text-blue-700"
>
Enable all
</button>
<button
type="button"
onClick={handleDisableSuggested}
className="text-xs text-gray-500 hover:text-gray-700"
>
Disable all
</button>
</div>
</div>
{pinnedFiltered.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
isEnabled={allowedTools.includes(tool.name)}
isEditExpanded={expandedTools.has(tool.name)}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToggle={handleToolToggle}
onToggleExpand={handleToggleEditExpanded}
onDisplayNameChange={handleDisplayNameChange}
onDescriptionChange={handleDescriptionChange}
/>
))}
</>
)}
{restFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1 pt-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{pinnedFiltered.length > 0 ? "All tools" : "Tools"}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableRest}
className="text-xs text-blue-600 hover:text-blue-700"
>
Enable all
</button>
<button
type="button"
onClick={handleDisableRest}
className="text-xs text-gray-500 hover:text-gray-700"
>
Disable all
</button>
</div>
</div>
{restFiltered.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
isEnabled={allowedTools.includes(tool.name)}
isEditExpanded={expandedTools.has(tool.name)}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToggle={handleToolToggle}
onToggleExpand={handleToggleEditExpanded}
onDisplayNameChange={handleDisplayNameChange}
onDescriptionChange={handleDescriptionChange}
/>
))}
</>
)}
{pinnedFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Suggested tools
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableSuggested}
className="text-xs text-primary hover:text-primary/80"
>
Enable all
</button>
<button
type="button"
onClick={handleDisableSuggested}
className="text-xs text-muted-foreground hover:text-foreground"
>
Disable all
</button>
</div>
</div>
{pinnedFiltered.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
isEnabled={allowedTools.includes(tool.name)}
isEditExpanded={expandedTools.has(tool.name)}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToggle={handleToolToggle}
onToggleExpand={handleToggleEditExpanded}
onDisplayNameChange={handleDisplayNameChange}
onDescriptionChange={handleDescriptionChange}
/>
))}
</>
)}
{restFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1 pt-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{pinnedFiltered.length > 0 ? "All tools" : "Tools"}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableRest}
className="text-xs text-primary hover:text-primary/80"
>
Enable all
</button>
<button
type="button"
onClick={handleDisableRest}
className="text-xs text-muted-foreground hover:text-foreground"
>
Disable all
</button>
</div>
</div>
{restFiltered.map((tool) => (
<ToolRow
key={tool.name}
tool={tool}
isEnabled={allowedTools.includes(tool.name)}
isEditExpanded={expandedTools.has(tool.name)}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToggle={handleToolToggle}
onToggleExpand={handleToggleEditExpanded}
onDisplayNameChange={handleDisplayNameChange}
onDescriptionChange={handleDescriptionChange}
/>
))}
</>
)}
</div>
)}
</>