first commit
This commit is contained in:
997
ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx
Normal file
997
ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx
Normal file
@@ -0,0 +1,997 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { HeadersTable } from "@/components/ui/headersTable";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MultiSelect } from "@/components/ui/multiSelect";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { TriStateCheckbox } from "@/components/ui/tristateCheckbox";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useDebouncedValue } from "@/hooks/useDebounce";
|
||||
import { MCP_STATUS_COLORS } from "@/lib/constants/config";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useGetVirtualKeysQuery, useUpdateMCPClientMutation } from "@/lib/store";
|
||||
import { MCPClient, MCPVKConfig } from "@/lib/types/mcp";
|
||||
import { mcpClientUpdateSchema, type MCPClientUpdateSchema } from "@/lib/types/schemas";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ChevronDown, ChevronRight, Info, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface MCPClientSheetProps {
|
||||
mcpClient: MCPClient;
|
||||
onClose: () => void;
|
||||
onSubmitSuccess: () => void;
|
||||
}
|
||||
|
||||
/** API sends tool_sync_interval as nanoseconds (Go time.Duration). Normalize to minutes for form/store. */
|
||||
function toolSyncIntervalToMinutes(v: number | undefined | null): number {
|
||||
if (v === undefined || v === null) return 0;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return 0;
|
||||
if (Math.abs(n) >= 1e9) return Math.round(n / 6e10);
|
||||
return n;
|
||||
}
|
||||
|
||||
export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: MCPClientSheetProps) {
|
||||
const hasUpdateMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Update);
|
||||
const [updateMCPClient, { isLoading: isUpdating }] = useUpdateMCPClientMutation();
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const globalToolSyncInterval = bifrostConfig?.client_config?.mcp_tool_sync_interval ?? 10;
|
||||
const { toast } = useToast();
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
// VK access management — search-based dropdown (limit 20), no pagination issue
|
||||
const [vkSearch, setVKSearch] = useState("");
|
||||
const [vkPopoverOpen, setVKPopoverOpen] = useState(false);
|
||||
const debouncedVkSearch = useDebouncedValue(vkSearch, 300);
|
||||
const { data: vksData } = useGetVirtualKeysQuery({ limit: 20, search: debouncedVkSearch || undefined });
|
||||
const allToolNames = useMemo(() => mcpClient.tools?.map((t) => t.name) ?? [], [mcpClient.tools]);
|
||||
|
||||
// Initial VK configs come directly from the MCP client response — always complete, no pagination issue.
|
||||
const initialVKConfigs = useMemo<MCPVKConfig[]>(
|
||||
() => (mcpClient.vk_configs ?? []).map((vc) => ({ virtual_key_id: vc.virtual_key_id, tools_to_execute: vc.tools_to_execute })),
|
||||
[mcpClient.vk_configs],
|
||||
);
|
||||
|
||||
const [vkConfigs, setVKConfigs] = useState<MCPVKConfig[]>([]);
|
||||
const [vkConfigsDirty, setVKConfigsDirty] = useState(false);
|
||||
const [allowedExtraHeadersRaw, setAllowedExtraHeadersRaw] = useState<string>((mcpClient.config.allowed_extra_headers || []).join(", "));
|
||||
// Persists names for newly added VKs so they survive search result changes
|
||||
const [localVKNames, setLocalVKNames] = useState<Record<string, string>>({});
|
||||
|
||||
// Sync vkConfigs when mcpClient changes
|
||||
useEffect(() => {
|
||||
setVKConfigs(initialVKConfigs);
|
||||
setVKConfigsDirty(false);
|
||||
setLocalVKNames({});
|
||||
}, [initialVKConfigs]);
|
||||
|
||||
// Sync allowedExtraHeadersRaw when mcpClient changes
|
||||
useEffect(() => {
|
||||
setAllowedExtraHeadersRaw((mcpClient.config.allowed_extra_headers || []).join(", "));
|
||||
}, [mcpClient.config.allowed_extra_headers]);
|
||||
|
||||
// Name lookup: server response names → search results → locally cached names (highest priority)
|
||||
const vkNameByID = useMemo<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const vc of mcpClient.vk_configs ?? []) m[vc.virtual_key_id] = vc.virtual_key_name;
|
||||
for (const vk of vksData?.virtual_keys ?? []) m[vk.id] = vk.name;
|
||||
Object.assign(m, localVKNames);
|
||||
return m;
|
||||
}, [mcpClient.vk_configs, vksData, localVKNames]);
|
||||
|
||||
const vkOptions = useMemo(
|
||||
() =>
|
||||
(vksData?.virtual_keys ?? [])
|
||||
.filter((vk) => !vkConfigs.some((vc) => vc.virtual_key_id === vk.id))
|
||||
.map((vk) => ({ value: vk.id, label: vk.name })),
|
||||
[vksData, vkConfigs],
|
||||
);
|
||||
|
||||
const toolOptions = useMemo(
|
||||
() => [
|
||||
{ value: "*", label: "Allow All Tools", description: "Allow all current and future tools" },
|
||||
...allToolNames.map((n) => ({ value: n, label: n })),
|
||||
],
|
||||
[allToolNames],
|
||||
);
|
||||
|
||||
const addVKConfig = (vkId: string) => {
|
||||
const name = vksData?.virtual_keys?.find((vk) => vk.id === vkId)?.name;
|
||||
if (name) setLocalVKNames((prev) => ({ ...prev, [vkId]: name }));
|
||||
setVKConfigs((prev) => [...prev, { virtual_key_id: vkId, tools_to_execute: ["*"] }]);
|
||||
setVKConfigsDirty(true);
|
||||
};
|
||||
|
||||
const removeVKConfig = (vkId: string) => {
|
||||
setVKConfigs((prev) => prev.filter((vc) => vc.virtual_key_id !== vkId));
|
||||
setVKConfigsDirty(true);
|
||||
};
|
||||
|
||||
const updateVKConfigTools = (vkId: string, tools: string[]) => {
|
||||
setVKConfigs((prev) => prev.map((vc) => (vc.virtual_key_id === vkId ? { ...vc, tools_to_execute: tools } : vc)));
|
||||
setVKConfigsDirty(true);
|
||||
};
|
||||
|
||||
const toggleToolExpanded = (toolName: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolName)) {
|
||||
next.delete(toolName);
|
||||
} else {
|
||||
next.add(toolName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useForm<MCPClientUpdateSchema>({
|
||||
resolver: zodResolver(mcpClientUpdateSchema),
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
name: mcpClient.config.name,
|
||||
is_code_mode_client: mcpClient.config.is_code_mode_client || false,
|
||||
is_ping_available: mcpClient.config.is_ping_available === true || mcpClient.config.is_ping_available === undefined,
|
||||
allow_on_all_virtual_keys: mcpClient.config.allow_on_all_virtual_keys || false,
|
||||
headers: mcpClient.config.headers,
|
||||
tools_to_execute: mcpClient.config.tools_to_execute || [],
|
||||
tools_to_auto_execute: mcpClient.config.tools_to_auto_execute || [],
|
||||
tool_pricing: mcpClient.config.tool_pricing || {},
|
||||
tool_sync_interval: toolSyncIntervalToMinutes(mcpClient.config.tool_sync_interval),
|
||||
allowed_extra_headers: mcpClient.config.allowed_extra_headers || [],
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when mcpClient changes
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
name: mcpClient.config.name,
|
||||
is_code_mode_client: mcpClient.config.is_code_mode_client || false,
|
||||
is_ping_available: mcpClient.config.is_ping_available === true || mcpClient.config.is_ping_available === undefined,
|
||||
allow_on_all_virtual_keys: mcpClient.config.allow_on_all_virtual_keys || false,
|
||||
headers: mcpClient.config.headers,
|
||||
tools_to_execute: mcpClient.config.tools_to_execute || [],
|
||||
tools_to_auto_execute: mcpClient.config.tools_to_auto_execute || [],
|
||||
tool_pricing: mcpClient.config.tool_pricing || {},
|
||||
tool_sync_interval: toolSyncIntervalToMinutes(mcpClient.config.tool_sync_interval),
|
||||
allowed_extra_headers: mcpClient.config.allowed_extra_headers || [],
|
||||
});
|
||||
}, [form, mcpClient]);
|
||||
|
||||
const onSubmit = async (data: MCPClientUpdateSchema) => {
|
||||
try {
|
||||
await updateMCPClient({
|
||||
id: mcpClient.config.client_id,
|
||||
data: {
|
||||
name: data.name,
|
||||
is_code_mode_client: data.is_code_mode_client,
|
||||
is_ping_available: data.is_ping_available,
|
||||
allow_on_all_virtual_keys: data.allow_on_all_virtual_keys,
|
||||
headers: data.headers ?? {},
|
||||
tools_to_execute: data.tools_to_execute,
|
||||
tools_to_auto_execute: data.tools_to_auto_execute,
|
||||
tool_pricing: data.tool_pricing,
|
||||
tool_sync_interval: data.tool_sync_interval ?? 0,
|
||||
allowed_extra_headers: data.allowed_extra_headers,
|
||||
vk_configs: vkConfigsDirty ? vkConfigs : undefined,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "MCP client updated successfully",
|
||||
});
|
||||
onSubmitSuccess();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolToggle = (toolName: string, checked: boolean) => {
|
||||
const currentTools = form.getValues("tools_to_execute") || [];
|
||||
let newTools: string[];
|
||||
const allToolNames = mcpClient.tools?.map((tool) => tool.name) || [];
|
||||
|
||||
// Check if we're in "all tools" mode (wildcard)
|
||||
const isAllToolsMode = currentTools.includes("*");
|
||||
|
||||
if (isAllToolsMode) {
|
||||
if (checked) {
|
||||
// Already all selected, keep wildcard
|
||||
newTools = ["*"];
|
||||
} else {
|
||||
// Unchecking a tool when all are selected - switch to explicit list without this tool
|
||||
newTools = allToolNames.filter((name) => name !== toolName);
|
||||
}
|
||||
} else {
|
||||
// We're in explicit tool selection mode
|
||||
if (checked) {
|
||||
// Add tool to selection
|
||||
newTools = currentTools.includes(toolName) ? currentTools : [...currentTools, toolName];
|
||||
|
||||
// If we now have all tools selected, switch to wildcard mode
|
||||
if (newTools.length === allToolNames.length) {
|
||||
newTools = ["*"];
|
||||
}
|
||||
} else {
|
||||
// Remove tool from selection
|
||||
newTools = currentTools.filter((tool) => tool !== toolName);
|
||||
}
|
||||
}
|
||||
|
||||
form.setValue("tools_to_execute", newTools, { shouldDirty: true });
|
||||
|
||||
// If tool is being removed from tools_to_execute, also remove it from tools_to_auto_execute
|
||||
if (!checked) {
|
||||
const currentAutoExecute = form.getValues("tools_to_auto_execute") || [];
|
||||
if (currentAutoExecute.includes(toolName) || currentAutoExecute.includes("*")) {
|
||||
const newAutoExecute = currentAutoExecute.filter((tool) => tool !== toolName);
|
||||
// If we had "*" and removed a tool, we need to recalculate
|
||||
if (currentAutoExecute.includes("*")) {
|
||||
// If all tools mode, keep "*" only if tool is still in tools_to_execute
|
||||
if (newTools.includes("*")) {
|
||||
form.setValue("tools_to_auto_execute", ["*"], { shouldDirty: true });
|
||||
} else {
|
||||
// Switch to explicit list - when in wildcard mode, all remaining tools should be auto-execute
|
||||
form.setValue("tools_to_auto_execute", newTools, { shouldDirty: true });
|
||||
}
|
||||
} else {
|
||||
form.setValue("tools_to_auto_execute", newAutoExecute, { shouldDirty: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoExecuteToggle = (toolName: string, checked: boolean) => {
|
||||
const currentAutoExecute = form.getValues("tools_to_auto_execute") || [];
|
||||
const currentTools = form.getValues("tools_to_execute") || [];
|
||||
const allToolNames = mcpClient.tools?.map((tool) => tool.name) || [];
|
||||
|
||||
// Check if we're in "all tools" mode (wildcard)
|
||||
const isAllToolsMode = currentTools.includes("*");
|
||||
const isAllAutoExecuteMode = currentAutoExecute.includes("*");
|
||||
|
||||
let newAutoExecute: string[];
|
||||
|
||||
if (isAllAutoExecuteMode) {
|
||||
if (checked) {
|
||||
// Already all selected, keep wildcard
|
||||
newAutoExecute = ["*"];
|
||||
} else {
|
||||
// Unchecking a tool when all are selected - switch to explicit list without this tool
|
||||
if (isAllToolsMode) {
|
||||
newAutoExecute = allToolNames.filter((name) => name !== toolName);
|
||||
} else {
|
||||
newAutoExecute = currentTools.filter((name) => name !== toolName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We're in explicit tool selection mode
|
||||
if (checked) {
|
||||
// Add tool to selection
|
||||
newAutoExecute = currentAutoExecute.includes(toolName) ? currentAutoExecute : [...currentAutoExecute, toolName];
|
||||
|
||||
// Only switch to wildcard if ALL tools are enabled (tools_to_execute is "*")
|
||||
// and all of those tools are now auto-executed. When specific tools are
|
||||
// explicitly listed, keep the explicit list to avoid sending "*" when only
|
||||
// a subset of tools is enabled.
|
||||
if (
|
||||
isAllToolsMode &&
|
||||
newAutoExecute.length === allToolNames.length &&
|
||||
allToolNames.every((tool) => newAutoExecute.includes(tool))
|
||||
) {
|
||||
newAutoExecute = ["*"];
|
||||
}
|
||||
} else {
|
||||
// Remove tool from selection
|
||||
newAutoExecute = currentAutoExecute.filter((tool) => tool !== toolName);
|
||||
}
|
||||
}
|
||||
|
||||
form.setValue("tools_to_auto_execute", newAutoExecute, { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open onOpenChange={onClose}>
|
||||
<SheetContent className="flex w-full flex-col overflow-x-hidden p-8 sm:max-w-[60%]">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col">
|
||||
<SheetHeader className="w-full p-0" showCloseButton={false}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<SheetTitle className="flex w-fit items-center gap-2 font-medium">
|
||||
{mcpClient.config.name}
|
||||
<Badge className={MCP_STATUS_COLORS[mcpClient.state]}>{mcpClient.state}</Badge>
|
||||
</SheetTitle>
|
||||
<SheetDescription>MCP server configuration and available tools</SheetDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
type="submit"
|
||||
disabled={isUpdating || (!form.formState.isDirty && !vkConfigsDirty) || !hasUpdateMCPClientAccess}
|
||||
isLoading={isUpdating}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="gap-6 space-y-6">
|
||||
{/* Name and Header Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Basic Information</h3>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground h-4 w-4 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
Use a descriptive, meaningful name that clearly identifies the server. For example, use "google_drive"
|
||||
instead of "gdrive", or "hacker_news" instead of "hn". This name is used as the Python module name in code
|
||||
mode.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl>
|
||||
<Input placeholder="Client name" {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_code_mode_client"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<FormLabel>Code Mode Client</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value || false} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_ping_available"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Ping Available for Health Check</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground h-4 w-4 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
Enable to use lightweight ping method for health checks. Disable if your MCP server doesn't support ping -
|
||||
will use listTools instead.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value === true} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allow_on_all_virtual_keys"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Allow on All Virtual Keys</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground h-4 w-4 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
When enabled, this MCP server is accessible to all virtual keys without requiring explicit per-key
|
||||
assignment. All tools are allowed by default. If a virtual key has an explicit MCP config for this server,
|
||||
that config takes precedence and overrides this behaviour.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value === true}
|
||||
onCheckedChange={field.onChange}
|
||||
data-testid="mcpclient-allow-on-all-virtual-keys-switch"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tool_sync_interval"
|
||||
render={({ field }) => {
|
||||
const isUsingGlobal = field.value === undefined || field.value === null || field.value === 0;
|
||||
return (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-2">
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div>
|
||||
<FormLabel>Tool Sync Interval (minutes)</FormLabel>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground h-4 w-4 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
Override the global tool sync interval for this server. Leave empty to use global setting. Set to -1 to
|
||||
disable sync for this server.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div>{isUsingGlobal && <p className="text-muted-foreground text-xs">Using global setting</p>}</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
className={`w-24 ${isUsingGlobal ? "text-muted-foreground" : ""}`}
|
||||
placeholder={String(globalToolSyncInterval)}
|
||||
value={field.value === 0 || field.value === undefined ? "" : String(field.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === "" ? undefined : parseInt(e.target.value);
|
||||
field.onChange(val);
|
||||
}}
|
||||
min="-1"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="headers"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-3">
|
||||
<FormControl>
|
||||
<HeadersTable
|
||||
value={field.value || {}}
|
||||
onChange={field.onChange}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
label="Headers"
|
||||
useEnvVarInput
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowed_extra_headers"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Allowed Extra Headers</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground h-4 w-4 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>Allowlist of headers that callers can forward to this MCP server at request time.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="mcpclient-input-allowed-extra-headers"
|
||||
placeholder="*, or: authorization, x-user-id"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={allowedExtraHeadersRaw}
|
||||
onChange={(e) => {
|
||||
setAllowedExtraHeadersRaw(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = allowedExtraHeadersRaw.trim()
|
||||
? allowedExtraHeadersRaw
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
field.onChange(parsed);
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Comma-separated header names, or <code>*</code> to allow all. Leave empty to block all extra headers.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Client Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Configuration</h3>
|
||||
<div className="rounded-sm border">
|
||||
<div className="bg-muted/50 text-muted-foreground border-b px-6 py-2 text-xs font-medium">Client ConnectionConfig</div>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={300}
|
||||
wrap={true}
|
||||
code={JSON.stringify(
|
||||
(() => {
|
||||
const {
|
||||
client_id: _client_id,
|
||||
name: _name,
|
||||
tools_to_execute: _tools_to_execute,
|
||||
headers: _headers,
|
||||
...rest
|
||||
} = mcpClient.config;
|
||||
return rest;
|
||||
})(),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{
|
||||
scrollBeyondLastLine: false,
|
||||
collapsibleBlocks: true,
|
||||
lineNumbers: "off",
|
||||
alwaysConsumeMouseWheel: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tools Section */}
|
||||
<div className="space-y-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">Available Tools ({mcpClient.tools?.length || 0})</h3>
|
||||
{mcpClient.tools && mcpClient.tools.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Enable All */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tools_to_execute"
|
||||
render={() => {
|
||||
const currentTools = form.watch("tools_to_execute") || [];
|
||||
const allToolNames = mcpClient.tools?.map((tool) => tool.name) || [];
|
||||
const isAllEnabled = currentTools.includes("*");
|
||||
const isNoneEnabled = currentTools.length === 0;
|
||||
const selectedIds = isAllEnabled ? allToolNames : currentTools;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{isAllEnabled ? "All enabled" : isNoneEnabled ? "None enabled" : `${currentTools.length} enabled`}
|
||||
</span>
|
||||
<TriStateCheckbox
|
||||
allIds={allToolNames}
|
||||
selectedIds={selectedIds}
|
||||
onChange={(nextSelectedIds) => {
|
||||
if (nextSelectedIds.length === 0) {
|
||||
form.setValue("tools_to_execute", [], { shouldDirty: true });
|
||||
// Also clear auto-execute when disabling all
|
||||
form.setValue("tools_to_auto_execute", [], { shouldDirty: true });
|
||||
} else if (nextSelectedIds.length === allToolNames.length) {
|
||||
form.setValue("tools_to_execute", ["*"], { shouldDirty: true });
|
||||
} else {
|
||||
form.setValue("tools_to_execute", nextSelectedIds, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* Auto-execute All */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tools_to_auto_execute"
|
||||
render={() => {
|
||||
const currentTools = form.watch("tools_to_execute") || [];
|
||||
const currentAutoExecute = form.watch("tools_to_auto_execute") || [];
|
||||
const allToolNames = mcpClient.tools?.map((tool) => tool.name) || [];
|
||||
|
||||
// Get the list of enabled tools
|
||||
const enabledToolNames = currentTools.includes("*") ? allToolNames : currentTools;
|
||||
const isAllAutoExecute = currentAutoExecute.includes("*");
|
||||
const isNoneAutoExecute = currentAutoExecute.length === 0;
|
||||
|
||||
// For TriStateCheckbox, we need the selected auto-execute tools that are also enabled
|
||||
const selectedAutoExecuteIds = isAllAutoExecute
|
||||
? enabledToolNames
|
||||
: currentAutoExecute.filter((t) => enabledToolNames.includes(t));
|
||||
|
||||
const autoExecuteCount = isAllAutoExecute ? enabledToolNames.length : selectedAutoExecuteIds.length;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{isAllAutoExecute
|
||||
? "All auto-execute"
|
||||
: isNoneAutoExecute
|
||||
? "None auto-execute"
|
||||
: `${autoExecuteCount} auto-execute`}
|
||||
</span>
|
||||
<TriStateCheckbox
|
||||
allIds={enabledToolNames}
|
||||
selectedIds={selectedAutoExecuteIds}
|
||||
disabled={enabledToolNames.length === 0}
|
||||
onChange={(nextSelectedIds) => {
|
||||
if (nextSelectedIds.length === 0) {
|
||||
form.setValue("tools_to_auto_execute", [], { shouldDirty: true });
|
||||
} else if (nextSelectedIds.length === enabledToolNames.length) {
|
||||
form.setValue("tools_to_auto_execute", ["*"], { shouldDirty: true });
|
||||
} else {
|
||||
form.setValue("tools_to_auto_execute", nextSelectedIds, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mcpClient.tools && mcpClient.tools.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
<TableHead className="max-w-[300px]">Tool Name</TableHead>
|
||||
<TableHead className="w-24 text-center">Enabled</TableHead>
|
||||
<TableHead className="w-28 text-center">Auto-execute</TableHead>
|
||||
<TableHead className="w-32 text-center">Cost (USD)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mcpClient.tools.map((tool, index) => {
|
||||
const currentTools = form.watch("tools_to_execute") || [];
|
||||
const currentAutoExecute = form.watch("tools_to_auto_execute") || [];
|
||||
const isToolEnabled = currentTools?.includes("*") || currentTools?.includes(tool.name);
|
||||
const isAutoExecuteEnabled =
|
||||
(currentAutoExecute?.includes("*") && isToolEnabled) ||
|
||||
(currentAutoExecute?.includes(tool.name) && isToolEnabled);
|
||||
const isExpanded = expandedTools.has(tool.name);
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<TableRow className="group">
|
||||
<TableCell className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
onClick={() => toggleToolExpanded(tool.name)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[300px]">
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground truncate text-sm font-medium">{tool.name}</div>
|
||||
{tool.description && (
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-xs">{tool.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tools_to_execute"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch
|
||||
size="md"
|
||||
checked={isToolEnabled}
|
||||
onCheckedChange={(checked) => handleToolToggle(tool.name, checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tools_to_auto_execute"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch
|
||||
size="md"
|
||||
checked={isAutoExecuteEnabled}
|
||||
disabled={!isToolEnabled}
|
||||
onCheckedChange={(checked) => handleAutoExecuteToggle(tool.name, checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tool_pricing"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
className="h-8 w-24"
|
||||
disabled={!isToolEnabled}
|
||||
value={field.value?.[tool.name] ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === "" ? undefined : parseFloat(e.target.value);
|
||||
const newPricing = { ...field.value };
|
||||
if (value === undefined || isNaN(value)) {
|
||||
delete newPricing[tool.name];
|
||||
} else {
|
||||
newPricing[tool.name] = value;
|
||||
}
|
||||
field.onChange(newPricing);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-0">
|
||||
<div className="bg-muted/30 border-b px-4 py-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">Parameters Schema</div>
|
||||
{tool.parameters ? (
|
||||
<CodeEditor
|
||||
className="z-0 w-full rounded-sm border"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={300}
|
||||
wrap={true}
|
||||
code={JSON.stringify(tool.parameters, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{
|
||||
scrollBeyondLastLine: false,
|
||||
collapsibleBlocks: true,
|
||||
lineNumbers: "off",
|
||||
alwaysConsumeMouseWheel: false,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">No parameters defined</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-sm border p-6 text-center">
|
||||
<p className="text-sm">No tools available</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mcpClient.tools && mcpClient.tools.length > 0 && (
|
||||
<div className="mt-6 space-y-4 pb-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-md font-semibold">Virtual Key Access</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground h-4 w-4 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>Control which virtual keys can use this MCP server and which specific tools they can call.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Popover
|
||||
open={vkPopoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
setVKPopoverOpen(open);
|
||||
if (!open) setVKSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7.5 gap-1.5 px-2 py-1 text-sm font-medium"
|
||||
data-testid="mcpclient-virtualkey-add-trigger"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Virtual Key
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-56 p-0">
|
||||
<div className="pb-1">
|
||||
<Input
|
||||
data-testid="mcpclient-virtualkey-search-input"
|
||||
placeholder="Search virtual keys..."
|
||||
value={vkSearch}
|
||||
onChange={(e) => setVKSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") e.preventDefault();
|
||||
}}
|
||||
className="h-7 rounded-b-none border-0 border-b text-sm focus-visible:ring-0"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto p-1">
|
||||
{vkOptions.length > 0 ? (
|
||||
vkOptions.map((opt) => (
|
||||
<button
|
||||
data-testid={`mcpclient-virtualkey-option-${opt.value}`}
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className="hover:bg-accent hover:text-accent-foreground w-full cursor-pointer rounded-sm px-2 py-1.5 text-left text-sm"
|
||||
onClick={() => {
|
||||
addVKConfig(opt.value);
|
||||
setVKSearch("");
|
||||
setVKPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-sm">No virtual keys found</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{form.watch("allow_on_all_virtual_keys") && (
|
||||
<p className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<Info className="h-3 w-3 shrink-0" />
|
||||
Configuring access for a virtual key here overrides the{" "}
|
||||
<span className="font-medium">Allow on All Virtual Keys</span> setting for that key.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vkConfigs.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Virtual Key</TableHead>
|
||||
<TableHead>Allowed Tools</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vkConfigs.map((vc) => (
|
||||
<TableRow key={vc.virtual_key_id}>
|
||||
<TableCell className="font-medium">{vkNameByID[vc.virtual_key_id] ?? vc.virtual_key_id}</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelect
|
||||
data-testid={`mcpclient-virtualkey-tool-selector-${vc.virtual_key_id}`}
|
||||
options={toolOptions}
|
||||
defaultValue={vc.tools_to_execute}
|
||||
resetOnDefaultValueChange
|
||||
onValueChange={(tools) => {
|
||||
const hadStar = vc.tools_to_execute.includes("*");
|
||||
const hasStar = tools.includes("*");
|
||||
let next: string[];
|
||||
if (!hadStar && hasStar) {
|
||||
next = ["*"];
|
||||
} else if (hadStar && hasStar && tools.length > 1) {
|
||||
next = tools.filter((t) => t !== "*");
|
||||
} else {
|
||||
next = tools;
|
||||
}
|
||||
updateVKConfigTools(vc.virtual_key_id, next);
|
||||
}}
|
||||
placeholder={
|
||||
vc.tools_to_execute.includes("*")
|
||||
? "All tools allowed"
|
||||
: vc.tools_to_execute.length === 0
|
||||
? "No tools allowed"
|
||||
: "Select tools..."
|
||||
}
|
||||
maxCount={3}
|
||||
className="bg-background dark:bg-input/30 border-input text-foreground hover:bg-accent hover:text-accent-foreground rounded-sm font-normal"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeVKConfig(vc.virtual_key_id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
data-testid={`mcpclient-virtualkey-remove-${vc.virtual_key_id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : form.watch("allow_on_all_virtual_keys") ? (
|
||||
<div className="text-muted-foreground rounded-sm border p-6 text-center">
|
||||
<p className="text-sm">All virtual keys can access this MCP server unless a key has an explicit override.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-sm border p-6 text-center">
|
||||
<p className="text-sm">No virtual keys have access to this MCP server</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user