630 lines
22 KiB
TypeScript
630 lines
22 KiB
TypeScript
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; |