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