997 lines
40 KiB
TypeScript
997 lines
40 KiB
TypeScript
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>
|
|
);
|
|
} |