Files
bifrost/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

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>&nbsp;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>
);
}