import { McpHttpProtocol, type CreateMcpServerRequest, type McpServer, type McpTransport, type ReplaceMcpServerRequest, } from "@qltysh/fabro-api-client"; import { looksLikeCredential, secretNameForKey, secretReference, } from "../lib/credential-heuristics"; import { MCP_TRANSPORT_KINDS, parseMcpTransportKind, type McpTransportKind, } from "../lib/mcp-transport-kinds"; import { KeyValueEditor, mapFromEntries, type KeyValueEntry } from "./key-value-editor"; import { Badge, Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; export interface McpServerFormValues { id: string; displayName: string; description: string; startupTimeoutSecs: number; toolTimeoutSecs: number; transport: McpTransportKind; // stdio + sandbox command: string; // http + sandbox protocol: McpHttpProtocol; // http url: string; headers: KeyValueEntry[]; // sandbox port: number; // stdio + sandbox env env: KeyValueEntry[]; } const DEFAULT_PROTOCOL: McpHttpProtocol = McpHttpProtocol.STREAMABLE_HTTP; const MCP_SERVER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/; const STARTUP_TIMEOUT_SECS = 10; const TOOL_TIMEOUT_SECS = 60; const DEFAULT_SANDBOX_PORT = 3000; export function defaultMcpServerFormValues(kind: McpTransportKind): McpServerFormValues { return { id: "", displayName: "", description: "", startupTimeoutSecs: STARTUP_TIMEOUT_SECS, toolTimeoutSecs: TOOL_TIMEOUT_SECS, transport: kind, command: "", protocol: DEFAULT_PROTOCOL, url: "", headers: [], port: DEFAULT_SANDBOX_PORT, env: [], }; } export function mcpServerToFormValues(server: McpServer): McpServerFormValues { const base = { ...defaultMcpServerFormValues(server.transport.type), id: server.id, displayName: server.display_name, description: server.description ?? "", startupTimeoutSecs: server.startup_timeout_secs, toolTimeoutSecs: server.tool_timeout_secs, }; switch (server.transport.type) { case "stdio": return { ...base, command: commandToInput(server.transport.command), env: entriesFromKeys(server.transport.env_keys), }; case "http": return { ...base, protocol: server.transport.protocol ?? DEFAULT_PROTOCOL, url: server.transport.url, headers: entriesFromKeys(server.transport.header_keys), }; case "sandbox": return { ...base, protocol: server.transport.protocol ?? DEFAULT_PROTOCOL, command: commandToInput(server.transport.command), port: server.transport.port, env: entriesFromKeys(server.transport.env_keys), }; } } function entriesFromKeys(keys: string[]): KeyValueEntry[] { return keys.map((key) => ({ key, value: "" })); } function commandToInput(command: string[]): string { return command.join(" "); } export function createRequestFromForm(values: McpServerFormValues): CreateMcpServerRequest { return { id: values.id.trim(), ...settingsFromForm(values), }; } export function replaceRequestFromForm(values: McpServerFormValues): ReplaceMcpServerRequest { return settingsFromForm(values); } function settingsFromForm(values: McpServerFormValues): ReplaceMcpServerRequest { return { display_name: values.displayName.trim(), description: values.description.trim() || null, transport: transportFromForm(values), startup_timeout_secs: values.startupTimeoutSecs, tool_timeout_secs: values.toolTimeoutSecs, }; } function transportFromForm(values: McpServerFormValues): McpTransport { switch (values.transport) { case "stdio": return { type: "stdio", command: commandFromInput(values.command), env: mapFromEntries(values.env), }; case "http": return { type: "http", ...protocolProperty(values.protocol), url: values.url.trim(), headers: mapFromEntries(values.headers), }; case "sandbox": return { type: "sandbox", ...protocolProperty(values.protocol), command: commandFromInput(values.command), port: values.port, env: mapFromEntries(values.env), }; } } function protocolProperty(protocol: McpHttpProtocol): { protocol?: McpHttpProtocol } { return protocol === DEFAULT_PROTOCOL ? {} : { protocol }; } function commandFromInput(command: string): string[] { return command.trim().split(/\s+/).filter(Boolean); } export function isMcpServerFormValid( values: McpServerFormValues, { isEdit }: { isEdit: boolean }, ): boolean { if (!isEdit && !MCP_SERVER_ID_PATTERN.test(values.id.trim())) return false; if (values.displayName.trim() === "") return false; switch (values.transport) { case "stdio": if (values.command.trim() === "") return false; break; case "http": if (values.url.trim() === "") return false; break; case "sandbox": if (values.command.trim() === "") return false; if (!Number.isInteger(values.port) || values.port < 1 || values.port > 65_535) { return false; } break; } if (isEdit && !writeOnlyRowsHaveValues(values)) return false; return true; } export function credentialWarnings( values: McpServerFormValues, ): { field: "env" | "headers"; index: number }[] { const field = values.transport === "http" ? "headers" : "env"; return activeValueEntries(values).flatMap((entry, index) => looksLikeCredential(entry.key, entry.value) ? [{ field, index }] : [], ); } function writeOnlyRowsHaveValues(values: McpServerFormValues): boolean { return activeValueEntries(values).every( (entry) => entry.key.trim() === "" || entry.value !== "", ); } function activeValueEntries(values: McpServerFormValues): KeyValueEntry[] { return values.transport === "http" ? values.headers : values.env; } interface McpServerFormFieldsProps { values: McpServerFormValues; onChange: (values: McpServerFormValues) => void; lockId?: boolean; lockTransport?: boolean; isEdit?: boolean; } export function McpServerFormFields({ values, onChange, lockId = false, lockTransport = false, isEdit = false, }: McpServerFormFieldsProps) { function patch(partial: Partial) { onChange({ ...values, ...partial }); } const idValid = MCP_SERVER_ID_PATTERN.test(values.id.trim()); return ( <> ID} help="Lowercase identifier (letters, digits, hyphens). Workflows enable this MCP server by id. Cannot be changed after creation." > {lockId ? (
{values.id}
) : ( patch({ id: e.target.value })} placeholder="github" autoComplete="off" spellCheck={false} className={`${INPUT_CLASS} font-mono`} /> )}
Display name} help="Human-readable name shown in this settings catalog."> patch({ displayName: e.target.value })} placeholder="GitHub MCP" autoComplete="off" spellCheck={false} className={INPUT_CLASS} /> Description} help="Optional note to help operators recognize this server."> patch({ description: e.target.value })} className={INPUT_CLASS} /> Transport} help="Choose how Fabro connects to this MCP server. The transport is fixed after creation." > {lockTransport ? ( {values.transport} ) : ( )} patch({ startupTimeoutSecs: Number(e.target.value) })} className={`${INPUT_CLASS} font-mono`} /> patch({ toolTimeoutSecs: Number(e.target.value) })} className={`${INPUT_CLASS} font-mono`} />
{values.transport === "stdio" ? ( ) : values.transport === "http" ? ( ) : ( )} {!lockId && values.id.trim() !== "" && !idValid ? (

ID must be lowercase letters, digits, or hyphens and start with a letter or digit.

) : null} ); } function StdioTransportFields({ values, patch, isEdit }: TransportFieldsProps) { return ( <> patch({ env })} isEdit={isEdit} /> ); } function HttpTransportFields({ values, patch, isEdit }: TransportFieldsProps) { return ( <> URL} help="Remote MCP endpoint URL."> patch({ url: e.target.value })} placeholder="https://example.com/mcp" autoComplete="off" spellCheck={false} className={`${INPUT_CLASS} font-mono`} /> patch({ headers })} isEdit={isEdit} /> ); } function SandboxTransportFields({ values, patch, isEdit }: TransportFieldsProps) { return ( <> Port} help="Port where the in-sandbox MCP server listens."> patch({ port: Number(e.target.value) })} className={`${INPUT_CLASS} font-mono`} /> patch({ env })} isEdit={isEdit} /> ); } interface TransportFieldsProps { values: McpServerFormValues; patch: (partial: Partial) => void; isEdit: boolean; } function CommandRow({ values, patch, help, placeholder, }: { values: McpServerFormValues; patch: (partial: Partial) => void; help: string; placeholder: string; }) { return ( Command} help={help}> patch({ command: e.target.value })} placeholder={placeholder} autoComplete="off" spellCheck={false} className={`${INPUT_CLASS} font-mono`} /> ); } function ProtocolRow({ values, patch, }: { values: McpServerFormValues; patch: (partial: Partial) => void; }) { return ( ); } function parseProtocol(value: string): McpHttpProtocol { return value === McpHttpProtocol.SSE ? McpHttpProtocol.SSE : DEFAULT_PROTOCOL; } function KeyValueRows({ field, entries, onChange, isEdit, }: { field: "env" | "headers"; entries: KeyValueEntry[]; onChange: (entries: KeyValueEntry[]) => void; isEdit: boolean; }) { const isHeaders = field === "headers"; return ( ( onChange(entries.map((e, i) => (i === index ? { ...e, value } : e))) } /> )} /> ); } function EntryHint({ entry, requireWriteOnlyValue, onStoreSecret, }: { entry: KeyValueEntry; requireWriteOnlyValue: boolean; onStoreSecret: (value: string) => void; }) { const missingWriteOnlyValue = requireWriteOnlyValue && entry.key.trim() !== "" && entry.value === ""; const credentialWarning = looksLikeCredential(entry.key, entry.value); if (!missingWriteOnlyValue && !credentialWarning) return null; const secretName = secretNameForKey(entry.key); return (
{missingWriteOnlyValue ? (

Enter a value for this existing write-only setting, or remove the row before saving.

) : null} {credentialWarning ? (

This looks like a credential.{" "}

) : null}
); } function openSecretCreateTab(secretName: string) { if (typeof window === "undefined") return; window.open( `/settings/secrets/new?name=${encodeURIComponent(secretName)}`, "_blank", "noopener,noreferrer", ); }