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 = ({ open, onClose, onSaved }) => { const hasCreateMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Create); const [form, setForm] = useState(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) => { 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 ( !open && onClose()}> New MCP Server Configure and connect to a new Model Context Protocol server.
) => handleChange("name", e.target.value)} placeholder="Server name" maxLength={50} />

Learn more about Code Mode

handleChange("is_code_mode_client", checked)} />

Enable to use lightweight ping method for health checks. Disable if your MCP server doesn't support ping - will use listTools instead.

handleChange("is_ping_available", checked)} />
{(form.connection_type === "http" || form.connection_type === "sse") && ( <>

Use env.<VAR> to read the value from an environment variable.

{form.auth_type === "headers" && (
{headersValidationError &&

{headersValidationError}

}
)} {(form.auth_type === "oauth" || form.auth_type === "per_user_oauth") && ( <>

Leave empty to use Dynamic Client Registration (RFC 7591). Bifrost will automatically register with the OAuth provider if supported.

) => handleOAuthConfigChange("client_id", e.target.value)} placeholder="your-client-id (auto-generated if empty)" />

Will be auto-generated via dynamic registration if left empty and provider supports it

) => handleOAuthConfigChange("client_secret", e.target.value)} placeholder="your-client-secret" />

Leave empty for public clients using PKCE

) => handleOAuthConfigChange("authorize_url", e.target.value)} placeholder="https://provider.com/oauth/authorize" />

Will be discovered from server if not provided

) => handleOAuthConfigChange("token_url", e.target.value)} placeholder="https://provider.com/oauth/token" />

Will be discovered from server if not provided

) => handleOAuthConfigChange("registration_url", e.target.value)} placeholder="https://provider.com/oauth/register" />

For dynamic client registration, will be discovered if not provided

) => setScopesText(e.target.value)} placeholder="read, write, admin" />

Will be discovered from server if not provided

)} )} {form.connection_type === "stdio" && ( <>

Docker Notice

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.

) => handleStdioConfigChange("command", e.target.value)} placeholder="node, python, /path/to/executable" data-testid="stdio-command-input" />
) => setArgsText(e.target.value)} placeholder="--port, 3000, --config, config.json" data-testid="stdio-args-input" />
) => setEnvsText(e.target.value)} placeholder="API_KEY, DATABASE_URL" data-testid="stdio-envs-input" />
)}
{/* Form Footer */}
{(!validator.isValid() || !hasCreateMCPClientAccess) && (

{!hasCreateMCPClientAccess ? "You don't have permission to perform this action" : validator.getFirstError() || "Please fix validation errors"}

)}
{/* OAuth Authorizer Popup */} {oauthFlow && ( { 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} /> )}
); }; export default ClientForm;