first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { NoPermissionView } from "@/components/noPermissionView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import MCPServersPage from "./page";
function RouteComponent() {
const hasMCPGatewayAccess = useRbac(RbacResource.MCPGateway, RbacOperation.View);
if (!hasMCPGatewayAccess) {
return <NoPermissionView entity="MCP gateway configuration" />;
}
return <MCPServersPage />;
}
export const Route = createFileRoute("/workspace/mcp-registry")({
component: RouteComponent,
});

View File

@@ -0,0 +1,79 @@
import FullPageLoader from "@/components/fullPageLoader";
import { useToast } from "@/hooks/use-toast";
import { useDebouncedValue } from "@/hooks/useDebounce";
import { getErrorMessage, useGetMCPClientsQuery } from "@/lib/store";
import { useEffect, useState } from "react";
import MCPClientsTable from "./views/mcpClientsTable";
const POLLING_INTERVAL = 5000;
const PAGE_SIZE = 25;
export default function MCPServersPage() {
const [search, setSearch] = useState("");
const [offset, setOffset] = useState(0);
const debouncedSearch = useDebouncedValue(search, 300);
// Reset to first page when search changes
useEffect(() => {
setOffset(0);
}, [debouncedSearch]);
const {
data: mcpClientsData,
error,
isLoading,
refetch,
} = useGetMCPClientsQuery(
{
limit: PAGE_SIZE,
offset,
search: debouncedSearch || undefined,
},
{
pollingInterval: POLLING_INTERVAL,
},
);
const mcpClients = mcpClientsData?.clients || [];
const totalCount = mcpClientsData?.total_count || 0;
// Snap offset back when total shrinks past current page (e.g. delete last item on last page)
useEffect(() => {
if (!mcpClientsData || offset < totalCount) return;
setOffset(totalCount === 0 ? 0 : Math.floor((totalCount - 1) / PAGE_SIZE) * PAGE_SIZE);
}, [totalCount, offset]);
const { toast } = useToast();
useEffect(() => {
if (error) {
const message = getErrorMessage(error);
if (message.toLowerCase().includes("mcp is not configured in this bifrost instance")) return;
toast({
title: "Error",
description: message,
variant: "destructive",
});
}
}, [error, toast]);
if (isLoading) {
return <FullPageLoader />;
}
return (
<div className="mx-auto w-full max-w-7xl">
<MCPClientsTable
mcpClients={mcpClients}
totalCount={totalCount}
refetch={refetch}
search={search}
debouncedSearch={debouncedSearch}
onSearchChange={setSearch}
offset={offset}
limit={PAGE_SIZE}
onOffsetChange={setOffset}
/>
</div>
);
}

View File

@@ -0,0 +1,630 @@
import { Button } from "@/components/ui/button";
import { EnvVarInput } from "@/components/ui/envVarInput";
import { HeadersTable } from "@/components/ui/headersTable";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/hooks/use-toast";
import { getErrorMessage, useCreateMCPClientMutation } from "@/lib/store";
import { CreateMCPClientRequest, EnvVar, MCPAuthType, MCPConnectionType, MCPStdioConfig, OAuthConfig } from "@/lib/types/mcp";
import { parseArrayFromText } from "@/lib/utils/array";
import { Validator } from "@/lib/utils/validation";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { Info } from "lucide-react";
import React, { useEffect, useState } from "react";
import { OAuth2Authorizer } from "./oauth2Authorizer";
interface ClientFormProps {
open: boolean;
onClose: () => void;
onSaved: () => void;
}
const emptyStdioConfig: MCPStdioConfig = {
command: "",
args: [],
envs: [],
};
const emptyEnvVar: EnvVar = { value: "", env_var: "", from_env: false };
const emptyOAuthConfig: OAuthConfig = {
client_id: "",
client_secret: "",
authorize_url: "",
token_url: "",
scopes: [],
};
const emptyForm: CreateMCPClientRequest = {
name: "",
is_code_mode_client: false,
is_ping_available: true,
connection_type: "http",
connection_string: emptyEnvVar,
stdio_config: emptyStdioConfig,
auth_type: "none",
};
const ClientForm: React.FC<ClientFormProps> = ({ open, onClose, onSaved }) => {
const hasCreateMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Create);
const [form, setForm] = useState<CreateMCPClientRequest>(emptyForm);
const [isLoading, setIsLoading] = useState(false);
const [argsText, setArgsText] = useState("");
const [envsText, setEnvsText] = useState("");
const [scopesText, setScopesText] = useState("");
const [oauthFlow, setOauthFlow] = useState<{
authorizeUrl: string;
oauthConfigId: string;
mcpClientId: string;
isPerUserOauth?: boolean;
} | null>(null);
const { toast } = useToast();
// RTK Query mutations
const [createMCPClient] = useCreateMCPClientMutation();
// Reset form state when dialog opens
useEffect(() => {
if (open) {
setForm(emptyForm);
setArgsText("");
setEnvsText("");
setScopesText("");
setOauthFlow(null);
setIsLoading(false);
}
}, [open]);
const handleChange = (
field: keyof CreateMCPClientRequest,
value: string | string[] | boolean | MCPConnectionType | MCPStdioConfig | undefined,
) => {
setForm((prev) => {
if (field === "connection_type" && value === "stdio") {
return {
...prev,
connection_type: "stdio" as MCPConnectionType,
auth_type: "none" as MCPAuthType,
headers: undefined,
oauth_config: undefined,
};
}
return { ...prev, [field]: value };
});
};
const handleStdioConfigChange = (field: keyof MCPStdioConfig, value: string | string[]) => {
setForm((prev) => ({
...prev,
stdio_config: {
command: "",
args: [],
envs: [],
...(prev.stdio_config || {}),
[field]: value,
},
}));
};
const handleHeadersChange = (value: Record<string, EnvVar>) => {
setForm((prev) => ({ ...prev, headers: value }));
};
const handleConnectionStringChange = (value: EnvVar) => {
setForm((prev) => ({
...prev,
connection_string: value,
}));
};
const handleOAuthConfigChange = (field: keyof OAuthConfig, value: string | string[]) => {
setForm((prev) => ({
...prev,
oauth_config: {
...(prev.oauth_config || emptyOAuthConfig),
[field]: value,
},
}));
};
// Validate headers format
const validateHeaders = (): string | null => {
if ((form.connection_type === "http" || form.connection_type === "sse") && form.auth_type === "headers" && form.headers) {
// Ensure all EnvVar values have either a value or env_var
for (const [key, envVar] of Object.entries(form.headers)) {
if (!envVar.value && !envVar.env_var) {
return `Header "${key}" must have a value`;
}
}
}
return null;
};
const headersValidationError = validateHeaders();
// Get the connection string value for validation
const connectionStringValue = form.connection_string?.value || "";
const validator = new Validator([
// Name validation
Validator.required(form.name?.trim(), "Server name is required"),
Validator.pattern(form.name || "", /^[a-zA-Z0-9_]+$/, "Server name can only contain letters, numbers, and underscores"),
Validator.custom(!(form.name || "").includes("-"), "Server name cannot contain hyphens"),
Validator.custom(!(form.name || "").includes(" "), "Server name cannot contain spaces"),
Validator.custom((form.name || "").length === 0 || !/^[0-9]/.test(form.name || ""), "Server name cannot start with a number"),
Validator.minLength(form.name || "", 3, "Server name must be at least 3 characters"),
Validator.maxLength(form.name || "", 50, "Server name cannot exceed 50 characters"),
// Connection type specific validation
...(form.connection_type === "http" || form.connection_type === "sse"
? [
Validator.required(connectionStringValue?.trim(), "Connection URL is required"),
Validator.pattern(
connectionStringValue,
/^((https?:\/\/.+)|(env\.[A-Z_]+))$/,
"Connection URL must start with http://, https://, or be an environment variable (env.VAR_NAME)",
),
...(headersValidationError ? [Validator.custom(false, headersValidationError)] : []),
]
: []),
// STDIO validation
...(form.connection_type === "stdio"
? [
Validator.required(form.stdio_config?.command?.trim(), "Command is required for STDIO connections"),
Validator.pattern(form.stdio_config?.command || "", /^[^<>|&;]+$/, "Command cannot contain special shell characters"),
]
: []),
// OAuth validation
...(form.auth_type === "oauth" || form.auth_type === "per_user_oauth"
? [
// Client ID is optional if provider supports dynamic registration (RFC 7591)
// URLs are optional (will be discovered), but if provided must be valid
...(form.oauth_config?.authorize_url
? [Validator.pattern(form.oauth_config.authorize_url, /^https?:\/\/.+$/, "Authorize URL must start with http:// or https://")]
: []),
...(form.oauth_config?.token_url
? [Validator.pattern(form.oauth_config.token_url, /^https?:\/\/.+$/, "Token URL must start with http:// or https://")]
: []),
...(form.oauth_config?.registration_url
? [
Validator.pattern(
form.oauth_config.registration_url,
/^https?:\/\/.+$/,
"Registration URL must start with http:// or https://",
),
]
: []),
]
: []),
]);
const handleSubmit = async () => {
// Validate before submitting
if (!validator.isValid()) {
toast({
title: "Validation Error",
description: validator.getFirstError() || "Please fix validation errors",
variant: "destructive",
});
return;
}
setIsLoading(true);
// Prepare the payload
const payload: CreateMCPClientRequest = {
...form,
stdio_config:
form.connection_type === "stdio"
? {
command: form.stdio_config?.command || "",
args: parseArrayFromText(argsText),
envs: parseArrayFromText(envsText),
}
: undefined,
oauth_config:
form.auth_type === "oauth" || form.auth_type === "per_user_oauth"
? {
client_id: form.oauth_config?.client_id || "", // Can be empty for dynamic registration
client_secret: form.oauth_config?.client_secret || undefined,
authorize_url: form.oauth_config?.authorize_url || undefined,
token_url: form.oauth_config?.token_url || undefined,
registration_url: form.oauth_config?.registration_url || undefined,
scopes: scopesText.trim() ? parseArrayFromText(scopesText) : undefined,
server_url: form.connection_string?.value || undefined, // Set server_url from connection_string
}
: undefined,
headers: form.auth_type === "headers" && form.headers && Object.keys(form.headers).length > 0 ? form.headers : undefined,
tools_to_execute: ["*"],
};
try {
const response = await createMCPClient(payload).unwrap();
// Check if OAuth flow was initiated
if (response.status === "pending_oauth" && response.authorize_url) {
setIsLoading(false);
// Open OAuth authorizer popup
setOauthFlow({
authorizeUrl: response.authorize_url,
oauthConfigId: response.oauth_config_id,
mcpClientId: response.mcp_client_id,
isPerUserOauth: form.auth_type === "per_user_oauth",
});
} else {
setIsLoading(false);
toast({
title: "Success",
description: "Server created",
});
onSaved();
onClose();
}
} catch (error) {
setIsLoading(false);
toast({ title: "Error", description: getErrorMessage(error), variant: "destructive" });
}
};
return (
<Sheet open={open} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="flex w-full flex-col overflow-x-hidden px-4 pb-8">
<SheetHeader className="flex flex-col items-start px-4 pt-8">
<SheetTitle>New MCP Server</SheetTitle>
<SheetDescription>Configure and connect to a new Model Context Protocol server.</SheetDescription>
</SheetHeader>
<div className="space-y-4 px-4">
<div className="space-y-2">
<Label htmlFor="client-name">Name</Label>
<Input
id="client-name"
data-testid="client-name-input"
value={form.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleChange("name", e.target.value)}
placeholder="Server name"
maxLength={50}
/>
</div>
<div className="w-full space-y-2">
<Label>Connection Type</Label>
<Select value={form.connection_type} onValueChange={(value: MCPConnectionType) => handleChange("connection_type", value)}>
<SelectTrigger className="w-full" data-testid="connection-type-select">
<SelectValue placeholder="Select connection type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="http" data-testid="connection-type-http">
HTTP (Streamable)
</SelectItem>
<SelectItem value="sse" data-testid="connection-type-sse">
Server-Sent Events (SSE)
</SelectItem>
<SelectItem value="stdio" data-testid="connection-type-stdio">
STDIO
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="flex items-center gap-2">
<Label htmlFor="code-mode">Code Mode Server</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://docs.getbifrost.ai/mcp/code-mode"
target="_blank"
rel="noopener noreferrer"
data-testid="code-mode-link-help"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded focus-visible:ring-2 focus-visible:outline-none"
aria-label="Learn more about Code Mode"
>
<Info className="h-4 w-4 cursor-help" />
</a>
</TooltipTrigger>
<TooltipContent>
<p>Learn more about Code Mode</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
id="code-mode"
data-testid="code-mode-switch"
checked={form.is_code_mode_client || false}
onCheckedChange={(checked) => handleChange("is_code_mode_client", checked)}
/>
</div>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="flex items-center gap-2">
<Label htmlFor="ping-available">Ping Available for Health Check</Label>
<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>
<Switch
id="ping-available"
checked={form.is_ping_available === true}
onCheckedChange={(checked) => handleChange("is_ping_available", checked)}
/>
</div>
{(form.connection_type === "http" || form.connection_type === "sse") && (
<>
<div className="space-y-2">
<div className="flex w-fit items-center gap-1">
<Label>Connection URL</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Info className="text-muted-foreground ml-1 h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-fit">
<p>
Use <code className="rounded bg-neutral-100 px-1 py-0.5 text-neutral-800">env.&lt;VAR&gt;</code> to read the value
from an environment variable.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<EnvVarInput
value={form.connection_string}
onChange={handleConnectionStringChange}
placeholder="http://your-mcp-server:3000 or env.MCP_SERVER_URL"
data-testid="connection-url-input"
/>
</div>
<div className="w-full space-y-2">
<Label>Authentication Type</Label>
<Select value={form.auth_type} onValueChange={(value: MCPAuthType) => handleChange("auth_type", value)}>
<SelectTrigger className="w-full" data-testid="auth-type-select">
<SelectValue placeholder="Select authentication type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" data-testid="auth-type-none">
None
</SelectItem>
<SelectItem value="headers" data-testid="auth-type-headers">
Headers
</SelectItem>
<SelectItem value="oauth" data-testid="auth-type-oauth">
OAuth 2.0
</SelectItem>
<SelectItem value="per_user_oauth" data-testid="auth-type-per-user-oauth">
Per-User OAuth 2.0
</SelectItem>
</SelectContent>
</Select>
</div>
{form.auth_type === "headers" && (
<div className="space-y-2" data-testid="mcp-headers-table">
<HeadersTable
value={form.headers || {}}
onChange={handleHeadersChange}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
label="Headers"
useEnvVarInput
/>
{headersValidationError && <p className="text-destructive text-xs">{headersValidationError}</p>}
</div>
)}
{(form.auth_type === "oauth" || form.auth_type === "per_user_oauth") && (
<>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>OAuth Client ID (optional)</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground h-4 w-4 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
Leave empty to use Dynamic Client Registration (RFC 7591). Bifrost will automatically register with the OAuth
provider if supported.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Input
value={form.oauth_config?.client_id || ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleOAuthConfigChange("client_id", e.target.value)}
placeholder="your-client-id (auto-generated if empty)"
/>
<p className="text-muted-foreground text-xs">
Will be auto-generated via dynamic registration if left empty and provider supports it
</p>
</div>
<div className="space-y-2">
<Label>OAuth Client Secret (optional for PKCE)</Label>
<Input
type="password"
value={form.oauth_config?.client_secret || ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleOAuthConfigChange("client_secret", e.target.value)}
placeholder="your-client-secret"
/>
<p className="text-muted-foreground text-xs">Leave empty for public clients using PKCE</p>
</div>
<div className="space-y-2">
<Label>Authorization URL (optional, auto-discovered)</Label>
<Input
value={form.oauth_config?.authorize_url || ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleOAuthConfigChange("authorize_url", e.target.value)}
placeholder="https://provider.com/oauth/authorize"
/>
<p className="text-muted-foreground text-xs">Will be discovered from server if not provided</p>
</div>
<div className="space-y-2">
<Label>Token URL (optional, auto-discovered)</Label>
<Input
value={form.oauth_config?.token_url || ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleOAuthConfigChange("token_url", e.target.value)}
placeholder="https://provider.com/oauth/token"
/>
<p className="text-muted-foreground text-xs">Will be discovered from server if not provided</p>
</div>
<div className="space-y-2">
<Label>Registration URL (optional, auto-discovered)</Label>
<Input
value={form.oauth_config?.registration_url || ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleOAuthConfigChange("registration_url", e.target.value)}
placeholder="https://provider.com/oauth/register"
/>
<p className="text-muted-foreground text-xs">For dynamic client registration, will be discovered if not provided</p>
</div>
<div className="space-y-2">
<Label>Scopes (optional, comma-separated)</Label>
<Input
value={scopesText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setScopesText(e.target.value)}
placeholder="read, write, admin"
/>
<p className="text-muted-foreground text-xs">Will be discovered from server if not provided</p>
</div>
</>
)}
</>
)}
{form.connection_type === "stdio" && (
<>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<div className="flex items-start gap-2">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-700" />
<div className="flex-1">
<p className="text-xs font-medium text-amber-900">Docker Notice</p>
<p className="mt-0.5 text-xs text-amber-800">
If not using the official Bifrost Docker image, STDIO connections may not work if required commands (npx, python,
etc.) aren't installed. You can safely ignore this if running locally or using a custom image with the necessary
dependencies.
</p>
</div>
</div>
</div>
<div className="space-y-2">
<Label>Command</Label>
<Input
value={form.stdio_config?.command || ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleStdioConfigChange("command", e.target.value)}
placeholder="node, python, /path/to/executable"
data-testid="stdio-command-input"
/>
</div>
<div className="space-y-2">
<Label>Arguments (comma-separated)</Label>
<Input
value={argsText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setArgsText(e.target.value)}
placeholder="--port, 3000, --config, config.json"
data-testid="stdio-args-input"
/>
</div>
<div className="space-y-2">
<Label>Environment Variables (comma-separated)</Label>
<Input
value={envsText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEnvsText(e.target.value)}
placeholder="API_KEY, DATABASE_URL"
data-testid="stdio-envs-input"
/>
</div>
</>
)}
</div>
{/* Form Footer */}
<div className="dark:bg-card border-border bg-white px-4 py-6">
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading} data-testid="cancel-client-btn">
Cancel
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
onClick={handleSubmit}
disabled={!validator.isValid() || isLoading || !hasCreateMCPClientAccess}
isLoading={isLoading}
data-testid="save-client-btn"
>
Create
</Button>
</span>
</TooltipTrigger>
{(!validator.isValid() || !hasCreateMCPClientAccess) && (
<TooltipContent>
<p>
{!hasCreateMCPClientAccess
? "You don't have permission to perform this action"
: validator.getFirstError() || "Please fix validation errors"}
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
</div>
</SheetContent>
{/* OAuth Authorizer Popup */}
{oauthFlow && (
<OAuth2Authorizer
open={!!oauthFlow}
onClose={() => {
setOauthFlow(null);
onClose();
}}
onSuccess={() => {
toast({
title: "Success",
description: "MCP server connected with OAuth",
});
onSaved();
setOauthFlow(null);
onClose();
}}
onError={(error) => {
toast({
title: "OAuth Error",
description: error,
variant: "destructive",
});
setOauthFlow(null);
}}
authorizeUrl={oauthFlow.authorizeUrl}
oauthConfigId={oauthFlow.oauthConfigId}
mcpClientId={oauthFlow.mcpClientId}
isPerUserOauth={oauthFlow.isPerUserOauth}
/>
)}
</Sheet>
);
};
export default ClientForm;

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

View File

@@ -0,0 +1,348 @@
import ClientForm from "@/app/workspace/mcp-registry/views/mcpClientForm";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alertDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { MCP_STATUS_COLORS } from "@/lib/constants/config";
import { getErrorMessage, useDeleteMCPClientMutation, useReconnectMCPClientMutation } from "@/lib/store";
import { MCPClient } from "@/lib/types/mcp";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { ChevronLeft, ChevronRight, Loader2, Plus, RefreshCcw, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { MCPServersEmptyState } from "./mcpServersEmptyState";
import MCPClientSheet from "./mcpClientSheet";
interface MCPClientsTableProps {
mcpClients: MCPClient[];
totalCount: number;
refetch?: () => void;
search: string;
debouncedSearch: string;
onSearchChange: (value: string) => void;
offset: number;
limit: number;
onOffsetChange: (offset: number) => void;
}
export default function MCPClientsTable({
mcpClients,
totalCount,
refetch,
search,
debouncedSearch,
onSearchChange,
offset,
limit,
onOffsetChange,
}: MCPClientsTableProps) {
const [formOpen, setFormOpen] = useState(false);
const hasCreateMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Create);
const hasUpdateMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Update);
const hasDeleteMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Delete);
const [selectedMCPClient, setSelectedMCPClient] = useState<MCPClient | null>(null);
const [showDetailSheet, setShowDetailSheet] = useState(false);
const { toast } = useToast();
const [reconnectingClients, setReconnectingClients] = useState<string[]>([]);
// RTK Query mutations
const [reconnectMCPClient] = useReconnectMCPClientMutation();
const [deleteMCPClient] = useDeleteMCPClientMutation();
const handleCreate = () => {
setFormOpen(true);
};
const handleReconnect = async (client: MCPClient) => {
try {
setReconnectingClients((prev) => [...prev, client.config.client_id]);
await reconnectMCPClient(client.config.client_id).unwrap();
setReconnectingClients((prev) => prev.filter((id) => id !== client.config.client_id));
toast({ title: "Reconnected", description: `Client ${client.config.name} reconnected successfully.` });
if (refetch) {
await refetch();
}
} catch (error) {
setReconnectingClients((prev) => prev.filter((id) => id !== client.config.client_id));
toast({ title: "Error", description: getErrorMessage(error), variant: "destructive" });
}
};
const handleDelete = async (client: MCPClient) => {
try {
await deleteMCPClient(client.config.client_id).unwrap();
toast({ title: "Deleted", description: `Client ${client.config.name} removed successfully.` });
if (refetch) {
await refetch();
}
} catch (error) {
toast({ title: "Error", description: getErrorMessage(error), variant: "destructive" });
}
};
const handleSaved = async () => {
setFormOpen(false);
if (refetch) {
await refetch();
}
};
const getConnectionDisplay = (client: MCPClient) => {
if (client.config.connection_type === "stdio") {
return `${client.config.stdio_config?.command} ${client.config.stdio_config?.args.join(" ")}` || "STDIO";
}
// connection_string is now an EnvVar, display the value or env_var reference
const connStr = client.config.connection_string;
if (connStr) {
return connStr.from_env ? connStr.env_var : connStr.value || `${client.config.connection_type.toUpperCase()}`;
}
return `${client.config.connection_type.toUpperCase()}`;
};
const getConnectionTypeDisplay = (type: string) => {
switch (type) {
case "http":
return "HTTP";
case "sse":
return "SSE";
case "stdio":
return "STDIO";
default:
return type.toUpperCase();
}
};
const handleRowClick = (mcpClient: MCPClient) => {
setSelectedMCPClient(mcpClient);
setShowDetailSheet(true);
};
const handleDetailSheetClose = () => {
setShowDetailSheet(false);
setSelectedMCPClient(null);
};
const handleEditTools = async () => {
setShowDetailSheet(false);
setSelectedMCPClient(null);
if (refetch) {
await refetch();
}
};
const hasActiveFilters = debouncedSearch;
// True empty state: no servers at all (not just filtered to zero)
if (totalCount === 0 && !hasActiveFilters) {
return (
<>
{formOpen && <ClientForm open={formOpen} onClose={() => setFormOpen(false)} onSaved={handleSaved} />}
<MCPServersEmptyState onAddClick={handleCreate} canCreate={hasCreateMCPClientAccess} />
</>
);
}
return (
<div className="space-y-4">
{showDetailSheet && selectedMCPClient && (
<MCPClientSheet mcpClient={selectedMCPClient} onClose={handleDetailSheetClose} onSubmitSuccess={handleEditTools} />
)}
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">MCP Server Catalog</h2>
<p className="text-muted-foreground text-sm">Manage servers that can connect to the MCP Tools endpoint.</p>
</div>
<Button onClick={handleCreate} disabled={!hasCreateMCPClientAccess} data-testid="create-mcp-client-btn" aria-label="New MCP Server" className="gap-2">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">New MCP Server</span>
</Button>
</div>
{/* Toolbar: Search */}
<div className="flex items-center gap-3">
<div className="relative max-w-sm flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
aria-label="Search MCP servers by name"
placeholder="Search by name..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
data-testid="mcp-clients-search-input"
/>
</div>
</div>
<div className="overflow-hidden rounded-sm border">
<Table data-testid="mcp-clients-table">
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">Name</TableHead>
<TableHead className="font-semibold">Connection Type</TableHead>
<TableHead className="font-semibold">Code Mode</TableHead>
<TableHead className="font-semibold">Connection Info</TableHead>
<TableHead className="font-semibold">Enabled Tools</TableHead>
<TableHead className="font-semibold">Auto-execute Tools</TableHead>
<TableHead className="font-semibold">State</TableHead>
<TableHead className="w-20 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mcpClients.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
<span className="text-muted-foreground text-sm">No matching MCP servers found.</span>
</TableCell>
</TableRow>
) : (
mcpClients.map((c: MCPClient) => {
const enabledToolsCount =
c.state == "connected"
? c.config.tools_to_execute?.includes("*")
? c.tools?.length
: (c.config.tools_to_execute?.length ?? 0)
: 0;
const autoExecuteToolsCount =
c.state == "connected"
? c.config.tools_to_auto_execute?.includes("*")
? c.tools?.length
: (c.config.tools_to_auto_execute?.length ?? 0)
: 0;
return (
<TableRow
key={c.config.client_id}
className="hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => handleRowClick(c)}
>
<TableCell className="font-medium">{c.config.name}</TableCell>
<TableCell data-testid="mcp-client-connection-type">{getConnectionTypeDisplay(c.config.connection_type)}</TableCell>
<TableCell>
<Badge
className={
c.state == "connected" ? MCP_STATUS_COLORS[c.config.is_code_mode_client ? "connected" : "disconnected"] : ""
}
>
{c.state == "connected" ? <>{c.config.is_code_mode_client ? "Enabled" : "Disabled"}</> : "-"}
</Badge>
</TableCell>
<TableCell className="max-w-72 overflow-hidden text-ellipsis whitespace-nowrap">{getConnectionDisplay(c)}</TableCell>
<TableCell>
{c.state == "connected" ? (
<>
{enabledToolsCount}/{c.tools?.length}
</>
) : (
"-"
)}
</TableCell>
<TableCell>
{c.state == "connected" ? (
<>
{autoExecuteToolsCount}/{c.tools?.length}
</>
) : (
"-"
)}
</TableCell>
<TableCell>
<Badge className={MCP_STATUS_COLORS[c.state]}>{c.state}</Badge>
</TableCell>
<TableCell className="space-x-2 text-right" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
onClick={() => handleReconnect(c)}
disabled={reconnectingClients.includes(c.config.client_id) || !hasUpdateMCPClientAccess}
title="Reconnect"
>
{reconnectingClients.includes(c.config.client_id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 hover:text-destructive border-destructive/30"
disabled={!hasDeleteMCPClientAccess}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove MCP Server</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove MCP server {c.config.name}? You will need to reconnect the server to continue
using it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(c)} className="bg-destructive hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalCount > 0 && (
<div className="flex items-center justify-between px-2">
<p className="text-muted-foreground text-sm">
Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={offset === 0}
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
data-testid="mcp-clients-pagination-prev-btn"
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={offset + limit >= totalCount}
onClick={() => onOffsetChange(offset + limit)}
data-testid="mcp-clients-pagination-next-btn"
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)}
{formOpen && <ClientForm open={formOpen} onClose={() => setFormOpen(false)} onSaved={handleSaved} />}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Button } from "@/components/ui/button";
import { Server } from "lucide-react";
import { ArrowUpRight } from "lucide-react";
const MCP_SERVERS_DOCS_URL = "https://docs.getbifrost.ai/features/mcp/overview";
interface MCPServersEmptyStateProps {
onAddClick: () => void;
canCreate?: boolean;
}
export function MCPServersEmptyState({ onAddClick, canCreate = true }: MCPServersEmptyStateProps) {
return (
<div className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center">
<div className="text-muted-foreground">
<Server className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
</div>
<div className="flex flex-col gap-1">
<h1 className="text-muted-foreground text-xl font-medium">MCP servers connect tools and context to the gateway</h1>
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
Add MCP servers to expose tools and resources to the MCP Tools endpoint. Configure connection type, auth, and which tools to
enable.
</div>
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
aria-label="Read more about MCP servers (opens in new tab)"
data-testid="mcp-registry-button-read-more"
onClick={() => {
window.open(`${MCP_SERVERS_DOCS_URL}?utm_source=bfd`, "_blank", "noopener,noreferrer");
}}
>
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
</Button>
<Button aria-label="Add your first MCP server" onClick={onAddClick} disabled={!canCreate} data-testid="create-mcp-client-btn">
Add MCP Server
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { getErrorMessage } from "@/lib/store/apis/baseApi";
import { useCompleteOAuthFlowMutation, useLazyGetOAuthConfigStatusQuery } from "@/lib/store/apis/mcpApi";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
interface OAuth2AuthorizerProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
onError: (error: string) => void;
authorizeUrl: string;
oauthConfigId: string;
mcpClientId: string;
isPerUserOauth?: boolean;
}
export const OAuth2Authorizer: React.FC<OAuth2AuthorizerProps> = ({
open,
onClose,
onSuccess,
onError,
authorizeUrl,
oauthConfigId,
isPerUserOauth,
}) => {
const [status, setStatus] = useState<"confirm" | "pending" | "polling" | "success" | "failed">(isPerUserOauth ? "confirm" : "pending");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const popupRef = useRef<Window | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const isCompletingRef = useRef(false);
// RTK Query hooks
const [getOAuthStatus] = useLazyGetOAuthConfigStatusQuery();
const [completeOAuth] = useCompleteOAuthFlowMutation();
// Stop polling
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}, []);
// Handle successful OAuth completion
const handleOAuthComplete = useCallback(async () => {
// Guard against concurrent calls (race between postMessage and polling)
if (isCompletingRef.current) return;
isCompletingRef.current = true;
// Close popup if still open
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
// Call complete-oauth endpoint using RTK Query mutation
// Use oauthConfigId instead of mcpClientId for multi-instance support
try {
await completeOAuth(oauthConfigId).unwrap();
setStatus("success");
onSuccess();
setTimeout(() => {
onClose();
}, 1000);
} catch (error) {
const errMsg = getErrorMessage(error);
setStatus("failed");
setErrorMessage(errMsg);
onError(errMsg);
}
}, [oauthConfigId, completeOAuth, onSuccess, onClose, onError]);
// Handle OAuth failure
const handleOAuthFailed = useCallback(
(reason: string) => {
stopPolling();
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
setStatus("failed");
setErrorMessage(reason);
onError(reason);
},
[stopPolling, onError],
);
// Check OAuth status (called by postMessage or polling)
const checkOAuthStatus = useCallback(async () => {
try {
const result = await getOAuthStatus(oauthConfigId).unwrap();
if (result.status === "authorized") {
stopPolling();
await handleOAuthComplete();
} else if (result.status === "failed" || result.status === "expired") {
handleOAuthFailed(`Authorization ${result.status}`);
}
} catch (error) {
console.error("Error checking OAuth status:", error);
}
}, [oauthConfigId, getOAuthStatus, stopPolling, handleOAuthComplete, handleOAuthFailed]);
// Poll OAuth status
const startPolling = useCallback(() => {
// Clear any existing interval
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
pollIntervalRef.current = setInterval(async () => {
// Check if popup is still open
if (popupRef.current && popupRef.current.closed) {
// Popup closed - check status before assuming cancellation
// (OAuth callback page closes the popup after success)
try {
const result = await getOAuthStatus(oauthConfigId).unwrap();
if (result.status === "authorized") {
stopPolling();
await handleOAuthComplete();
} else if (result.status === "failed" || result.status === "expired") {
stopPolling();
handleOAuthFailed("Authorization failed");
}
// pending or other non-terminal: let polling continue
} catch {
// transient fetch error: let polling continue
}
return;
}
await checkOAuthStatus();
}, 2000); // Poll every 2 seconds
}, [checkOAuthStatus, handleOAuthFailed]);
// Open popup and start polling
const openPopup = useCallback(() => {
// Reset completion guard for each fresh OAuth attempt
isCompletingRef.current = false;
// Close any existing popup
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
// Open OAuth popup
const width = 600;
const height = 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
popupRef.current = window.open(
authorizeUrl,
"oauth_popup",
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`,
);
setStatus("polling");
// Start polling OAuth status
startPolling();
}, [authorizeUrl, startPolling]);
// Listen for postMessage from OAuth callback popup
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Verify message is from OAuth callback
if (event.data?.type === "oauth_success") {
// Trigger immediate status check; stopPolling is called inside
// checkOAuthStatus only after a confirmed terminal state, so
// transient fetch errors still allow polling to continue.
checkOAuthStatus();
}
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [checkOAuthStatus]);
// Open popup when dialog opens (skip if waiting for user confirmation)
useEffect(() => {
if (open && status === "pending") {
openPopup();
}
}, [open, status, openPopup]);
// Handle user confirming per-user OAuth test
const handleConfirmPerUserOAuth = () => {
setStatus("pending");
openPopup();
};
// Cleanup on unmount
useEffect(() => {
return () => {
stopPolling();
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
};
}, [stopPolling]);
const handleRetry = () => {
setErrorMessage(null);
isCompletingRef.current = false;
if (isPerUserOauth) {
setStatus("confirm");
} else {
setStatus("pending");
openPopup();
}
};
const handleCancel = () => {
stopPolling();
isCompletingRef.current = false;
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
onClose();
};
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-md" onPointerDownOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>{status === "confirm" ? "Test OAuth Configuration" : "OAuth Authorization"}</DialogTitle>
<DialogDescription>
{status === "confirm" && "A one-time login is needed to verify your OAuth setup."}
{status === "pending" && "Opening authorization window..."}
{status === "polling" && "Waiting for authorization..."}
{status === "success" && "Authorization successful!"}
{status === "failed" && "Authorization failed"}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center justify-center space-y-4">
{status === "confirm" && (
<>
<div className="text-muted-foreground space-y-3 text-sm">
<p>
To set up this MCP server, we need to verify that your OAuth configuration is correct and discover the available tools.
</p>
<p>
You will be asked to log in to the OAuth provider. This is a <strong>one-time test</strong> to confirm the setup works.
Your credentials will <strong>not</strong> be stored or used for any other purpose.
</p>
<p>Once verified, each user will authenticate individually when they use this MCP server.</p>
</div>
<div className="flex w-full justify-end space-x-2">
<Button onClick={handleCancel} variant="outline" data-testid="per-user-oauth-cancel">
Cancel
</Button>
<Button onClick={handleConfirmPerUserOAuth} data-testid="per-user-oauth-confirm">
Continue with Test Login
</Button>
</div>
</>
)}
{status === "polling" && (
<>
<Loader2 className="text-secondary-foreground h-4 w-4 animate-spin" />
<p className="text-muted-foreground text-sm">Please complete authorization in the popup window</p>
</>
)}
{status === "success" && (
<>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm text-green-600">MCP server connected successfully!</p>
</>
)}
{status === "failed" && (
<>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm text-red-600">{errorMessage || "An error occurred"}</p>
<Button onClick={handleRetry} variant="outline">
Retry
</Button>
</>
)}
</div>
{status === "polling" && (
<div className="flex justify-end space-x-2">
<Button onClick={handleCancel} variant="outline">
Cancel
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
};