diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index f4dbea49b..5ea597b17 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -1,6 +1,4 @@ -import type { ReactNode } from "react"; import { Disclosure, DisclosureButton, DisclosurePanel, Switch } from "@headlessui/react"; -import { PlusIcon, XMarkIcon } from "@heroicons/react/16/solid"; import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { EnvironmentApiDockerfileSourceInlineTypeEnum, @@ -17,8 +15,14 @@ import type { ReplaceEnvironmentRequest, } from "@qltysh/fabro-api-client"; -import { Panel, Row } from "./settings-panel"; +import { Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; +import { + KeyValueEditor, + entriesFromMap, + mapFromEntries, + type KeyValueEntry, +} from "./key-value-editor"; // Providers a managed environment can be created with. `local` is a reserved, // in-memory environment, never a managed-environment provider, so it is never @@ -45,11 +49,6 @@ const CPU = { min: 1, max: 8, step: 1, default: 4 }; const MEMORY = { min: 1, max: 16, step: 1, default: 8 }; const DISK = { min: 1, max: 20, step: 1, default: 16 }; -interface KeyValueEntry { - key: string; - value: string; -} - // An environment image comes from exactly one source: a prebuilt image // reference or an inline Dockerfile. The form keeps both field values around so // switching back and forth doesn't lose typed text, and this discriminator @@ -226,18 +225,6 @@ function lifecycleFromForm(values: EnvironmentFormValues): EnvironmentLifecycleS }; } -function entriesFromMap(map: { [key: string]: string }): KeyValueEntry[] { - return Object.entries(map).map(([key, value]) => ({ key, value })); -} - -function mapFromEntries(entries: KeyValueEntry[]): { [key: string]: string } { - return Object.fromEntries( - entries - .map((entry): [string, string] => [entry.key.trim(), entry.value]) - .filter((entry) => entry[0] !== ""), - ); -} - function parseImageSource(value: string): ImageSource { return value === "dockerfile" ? "dockerfile" : "image"; } @@ -442,104 +429,6 @@ export function EnvironmentFormFields({ ); } -function KeyValueEditor({ - entries, - onChange, - keyPlaceholder, - valuePlaceholder, - addLabel, -}: { - entries: KeyValueEntry[]; - onChange: (entries: KeyValueEntry[]) => void; - keyPlaceholder: string; - valuePlaceholder: string; - addLabel: string; -}) { - function update(index: number, partial: Partial) { - onChange(entries.map((entry, i) => (i === index ? { ...entry, ...partial } : entry))); - } - - return ( -
- {entries.map((entry, index) => ( -
- update(index, { key: e.target.value })} - placeholder={keyPlaceholder} - autoComplete="off" - spellCheck={false} - className={`${INPUT_CLASS} font-mono`} - /> - update(index, { value: e.target.value })} - placeholder={valuePlaceholder} - autoComplete="off" - spellCheck={false} - className={`${INPUT_CLASS} font-mono`} - /> - onChange(entries.filter((_, i) => i !== index))} /> -
- ))} - onChange([...entries, { key: "", value: "" }])} /> -
- ); -} - -function AddButton({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - ); -} - -function RemoveButton({ onClick }: { onClick: () => void }) { - return ( - - ); -} - -function Label({ - children, - required, - optional, -}: { - children: ReactNode; - required?: boolean; - optional?: boolean; -}) { - return ( - - {children} - {required ? ( - - * - - ) : null} - {optional ? Optional : null} - - ); -} - function ResourceSlider({ value, range, diff --git a/apps/fabro-web/app/components/key-value-editor.test.tsx b/apps/fabro-web/app/components/key-value-editor.test.tsx new file mode 100644 index 000000000..89248261d --- /dev/null +++ b/apps/fabro-web/app/components/key-value-editor.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { useState } from "react"; +import TestRenderer, { act } from "react-test-renderer"; + +import { setupReactTestEnv } from "../lib/test-utils"; +import { KeyValueEditor, entriesFromMap, mapFromEntries } from "./key-value-editor"; + +let teardownReactTestEnv: (() => void) | undefined; +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +describe("key/value helpers", () => { + test("mapFromEntries trims keys and drops blank keys", () => { + expect( + mapFromEntries([ + { key: " FOO ", value: "bar" }, + { key: " ", value: "ignored" }, + { key: "BAZ", value: "qux" }, + ]), + ).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + test("entriesFromMap round-trips through mapFromEntries", () => { + const map = { FOO: "bar", BAZ: "qux" }; + expect(mapFromEntries(entriesFromMap(map))) + .toEqual(map); + }); +}); + +describe("KeyValueEditor", () => { + beforeEach(() => { + teardownReactTestEnv = setupReactTestEnv(); + }); + + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + teardownReactTestEnv?.(); + teardownReactTestEnv = undefined; + }); + + test("adds and removes rows", () => { + let renderer: TestRenderer.ReactTestRenderer | undefined; + + function Host() { + const [entries, setEntries] = useState([{ key: "FOO", value: "bar" }]); + return ( + + ); + } + + act(() => { + renderer = TestRenderer.create(); + }); + mountedRenderers.push(renderer!); + expect(renderer!.root.findAllByProps({ "aria-label": "Key" })).toHaveLength(1); + + const addButton = renderer!.root + .findAllByType("button") + .find((button) => button.props["aria-label"] === undefined); + expect(addButton).toBeDefined(); + act(() => { + addButton!.props.onClick(); + }); + expect(renderer!.root.findAllByProps({ "aria-label": "Key" })).toHaveLength(2); + + const removeButtons = renderer!.root.findAllByProps({ "aria-label": "Remove row" }); + act(() => { + removeButtons[0].props.onClick(); + }); + expect(renderer!.root.findAllByProps({ "aria-label": "Key" })).toHaveLength(1); + }); +}); diff --git a/apps/fabro-web/app/components/key-value-editor.tsx b/apps/fabro-web/app/components/key-value-editor.tsx new file mode 100644 index 000000000..fb4014d6d --- /dev/null +++ b/apps/fabro-web/app/components/key-value-editor.tsx @@ -0,0 +1,102 @@ +import type { ReactNode } from "react"; +import { PlusIcon, XMarkIcon } from "@heroicons/react/16/solid"; + +import { INPUT_CLASS } from "./ui"; + +export interface KeyValueEntry { + key: string; + value: string; +} + +export function entriesFromMap(map: { [key: string]: string }): KeyValueEntry[] { + return Object.entries(map).map(([key, value]) => ({ key, value })); +} + +export function mapFromEntries(entries: KeyValueEntry[]): { [key: string]: string } { + return Object.fromEntries( + entries + .map((entry): [string, string] => [entry.key.trim(), entry.value]) + .filter((entry) => entry[0] !== ""), + ); +} + +export function KeyValueEditor({ + entries, + onChange, + keyPlaceholder, + valuePlaceholder, + addLabel, + renderEntryHint, +}: { + entries: KeyValueEntry[]; + onChange: (entries: KeyValueEntry[]) => void; + keyPlaceholder: string; + valuePlaceholder: string; + addLabel: string; + renderEntryHint?: (entry: KeyValueEntry, index: number) => ReactNode; +}) { + function update(index: number, partial: Partial) { + onChange(entries.map((entry, i) => (i === index ? { ...entry, ...partial } : entry))); + } + + return ( +
+ {entries.map((entry, index) => ( +
+
+ update(index, { key: e.target.value })} + placeholder={keyPlaceholder} + autoComplete="off" + spellCheck={false} + className={`${INPUT_CLASS} font-mono`} + /> + update(index, { value: e.target.value })} + placeholder={valuePlaceholder} + autoComplete="off" + spellCheck={false} + className={`${INPUT_CLASS} font-mono`} + /> + onChange(entries.filter((_, i) => i !== index))} /> +
+ {renderEntryHint ? renderEntryHint(entry, index) : null} +
+ ))} + onChange([...entries, { key: "", value: "" }])} /> +
+ ); +} + +function AddButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +function RemoveButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/apps/fabro-web/app/components/mcp-server-form.test.tsx b/apps/fabro-web/app/components/mcp-server-form.test.tsx new file mode 100644 index 000000000..0c2bcb62e --- /dev/null +++ b/apps/fabro-web/app/components/mcp-server-form.test.tsx @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import type { McpServer } from "@qltysh/fabro-api-client"; + +import { + credentialWarnings, + createRequestFromForm, + defaultMcpServerFormValues, + isMcpServerFormValid, + mcpServerToFormValues, + replaceRequestFromForm, + type McpServerFormValues, +} from "./mcp-server-form"; + +function values(overrides: Partial = {}): McpServerFormValues { + return { + ...defaultMcpServerFormValues("stdio"), + id: "github", + displayName: "GitHub MCP", + command: "npx -y @modelcontextprotocol/server-github", + ...overrides, + }; +} + +function server(overrides: Partial = {}): McpServer { + return { + id: "github", + revision: "rev-1", + display_name: "GitHub MCP", + description: "Tools for GitHub", + startup_timeout_secs: 10, + tool_timeout_secs: 60, + transport: { + type: "stdio", + command: ["npx", "server"], + env_keys: ["GITHUB_TOKEN"], + }, + ...overrides, + }; +} + +describe("MCP server form helpers", () => { + test("uses MCP server defaults", () => { + expect(defaultMcpServerFormValues("http")).toMatchObject({ + transport: "http", + protocol: "streamable_http", + startupTimeoutSecs: 10, + toolTimeoutSecs: 60, + headers: [], + env: [], + }); + }); + + test("maps read models to form values with write-only values left empty", () => { + const form = mcpServerToFormValues(server({ + transport: { + type: "http", + protocol: "sse", + url: "https://example.com/mcp", + header_keys: ["Authorization", "X-API-Key"], + }, + })); + + expect(form).toMatchObject({ + id: "github", + displayName: "GitHub MCP", + description: "Tools for GitHub", + transport: "http", + protocol: "sse", + url: "https://example.com/mcp", + headers: [ + { key: "Authorization", value: "" }, + { key: "X-API-Key", value: "" }, + ], + }); + }); + + test("builds stdio create requests", () => { + const request = createRequestFromForm(values({ + id: " github ", + displayName: " GitHub MCP ", + description: " Tools ", + command: "npx server --flag", + env: [ + { key: " GITHUB_TOKEN ", value: "{{ secrets.GITHUB_TOKEN }}" }, + { key: "", value: "ignored" }, + ], + })); + + expect(request).toEqual({ + id: "github", + display_name: "GitHub MCP", + description: "Tools", + startup_timeout_secs: 10, + tool_timeout_secs: 60, + transport: { + type: "stdio", + command: ["npx", "server", "--flag"], + env: { GITHUB_TOKEN: "{{ secrets.GITHUB_TOKEN }}" }, + }, + }); + }); + + test("builds http replace requests and omits the default protocol", () => { + const request = replaceRequestFromForm(values({ + transport: "http", + protocol: "streamable_http", + url: " https://example.com/mcp ", + headers: [{ key: " Authorization ", value: "Bearer token" }], + })); + + expect(request.transport).toEqual({ + type: "http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer token" }, + }); + }); + + test("builds sandbox requests with an explicit non-default protocol", () => { + const request = replaceRequestFromForm(values({ + transport: "sandbox", + protocol: "sse", + command: "python server.py", + port: 7777, + env: [{ key: "NODE_ENV", value: "production" }], + })); + + expect(request.transport).toEqual({ + type: "sandbox", + protocol: "sse", + command: ["python", "server.py"], + port: 7777, + env: { NODE_ENV: "production" }, + }); + }); + + test("validates create fields per transport", () => { + expect(isMcpServerFormValid(values({ id: "bad_id" }), { isEdit: false })) + .toBe(false); + expect(isMcpServerFormValid(values({ displayName: "" }), { isEdit: false })) + .toBe(false); + expect(isMcpServerFormValid(values({ transport: "stdio", command: "" }), { isEdit: false })) + .toBe(false); + expect(isMcpServerFormValid(values({ transport: "http", url: "" }), { isEdit: false })) + .toBe(false); + expect(isMcpServerFormValid(values({ transport: "sandbox", port: 0 }), { isEdit: false })) + .toBe(false); + expect(isMcpServerFormValid(values({ transport: "sandbox", port: 65535 }), { isEdit: false })) + .toBe(true); + }); + + test("requires values for existing write-only rows on edit", () => { + const editValues = values({ + env: [{ key: "GITHUB_TOKEN", value: "" }], + }); + + expect(isMcpServerFormValid(editValues, { isEdit: false })) + .toBe(true); + expect(isMcpServerFormValid(editValues, { isEdit: true })) + .toBe(false); + expect( + isMcpServerFormValid( + { ...editValues, env: [{ key: "GITHUB_TOKEN", value: "{{ secrets.GITHUB_TOKEN }}" }] }, + { isEdit: true }, + ), + ).toBe(true); + }); + + test("reports credential warnings for the active key-value field only", () => { + expect( + credentialWarnings(values({ + env: [{ key: "API_KEY", value: "literal-secret" }], + headers: [{ key: "Authorization", value: "Bearer literal-secret" }], + })), + ).toEqual([{ field: "env", index: 0 }]); + + expect( + credentialWarnings(values({ + transport: "http", + env: [{ key: "API_KEY", value: "literal-secret" }], + headers: [{ key: "Authorization", value: "Bearer literal-secret" }], + })), + ).toEqual([{ field: "headers", index: 0 }]); + }); + + test("round-trips editable values while documenting omitted write-only values", () => { + const form = mcpServerToFormValues(server({ + transport: { + type: "sandbox", + command: ["node", "server.js"], + port: 3000, + env_keys: ["API_KEY"], + }, + })); + const request = replaceRequestFromForm(form); + + expect(form.id).toBe("github"); + expect(form.env).toEqual([{ key: "API_KEY", value: "" }]); + expect(request.display_name).toBe("GitHub MCP"); + expect(request.transport).toEqual({ + type: "sandbox", + command: ["node", "server.js"], + port: 3000, + env: { API_KEY: "" }, + }); + }); +}); diff --git a/apps/fabro-web/app/components/mcp-server-form.tsx b/apps/fabro-web/app/components/mcp-server-form.tsx new file mode 100644 index 000000000..3b298fb82 --- /dev/null +++ b/apps/fabro-web/app/components/mcp-server-form.tsx @@ -0,0 +1,564 @@ +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", + ); +} diff --git a/apps/fabro-web/app/components/settings-panel.tsx b/apps/fabro-web/app/components/settings-panel.tsx index 106327fda..e4365b388 100644 --- a/apps/fabro-web/app/components/settings-panel.tsx +++ b/apps/fabro-web/app/components/settings-panel.tsx @@ -51,6 +51,28 @@ export function Row({ ); } +export function Label({ + children, + required, + optional, +}: { + children: ReactNode; + required?: boolean; + optional?: boolean; +}) { + return ( + + {children} + {required ? ( + + * + + ) : null} + {optional ? Optional : null} + + ); +} + export function SettingsPageIntro({ description, action, diff --git a/apps/fabro-web/app/lib/api-client.ts b/apps/fabro-web/app/lib/api-client.ts index d06e17e0d..e77eb6d8a 100644 --- a/apps/fabro-web/app/lib/api-client.ts +++ b/apps/fabro-web/app/lib/api-client.ts @@ -12,6 +12,7 @@ import { HumanInTheLoopApi, InsightsApi, InstallApi, + MCPServersApi, ModelsApi, RunInternalsApi, RunOutputsApi, @@ -97,6 +98,11 @@ export const insightsApi = new InsightsApi( "", generatedAxios, ); +export const mcpServersApi = new MCPServersApi( + generatedApiConfiguration, + "", + generatedAxios, +); export const installApi = new InstallApi( generatedApiConfiguration, "", diff --git a/apps/fabro-web/app/lib/credential-heuristics.test.ts b/apps/fabro-web/app/lib/credential-heuristics.test.ts new file mode 100644 index 000000000..3f571c0f8 --- /dev/null +++ b/apps/fabro-web/app/lib/credential-heuristics.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { + looksLikeCredential, + secretNameForKey, + secretReference, +} from "./credential-heuristics"; + +describe("credential heuristics", () => { + test("flags Authorization bearer values", () => { + expect(looksLikeCredential("Authorization", "Bearer abc123")) + .toBe(true); + }); + + test("flags API key names", () => { + expect(looksLikeCredential("API_KEY", "abc")) + .toBe(true); + expect(looksLikeCredential("x-api-key", "abc")) + .toBe(true); + }); + + test("does not flag ordinary environment values", () => { + expect(looksLikeCredential("NODE_ENV", "production")) + .toBe(false); + }); + + test("does not flag already templated references", () => { + expect(looksLikeCredential("API_KEY", "{{ secrets.OPENAI_API_KEY }}")) + .toBe(false); + expect(looksLikeCredential("TOKEN", "{{ env.GITHUB_TOKEN }}")) + .toBe(false); + expect(looksLikeCredential("PASSWORD", "{{ vars.RUNTIME_PASSWORD }}")) + .toBe(false); + }); + + test("does not flag empty values", () => { + expect(looksLikeCredential("PASSWORD", "")) + .toBe(false); + }); + + test("flags long high-entropy-looking values under benign keys", () => { + expect(looksLikeCredential("session_id", "aBcdEf1234567890Ghij")) + .toBe(true); + }); + + test("derives secret names from keys", () => { + expect(secretNameForKey("x-api-key")) + .toBe("X_API_KEY"); + expect(secretNameForKey(" ")) + .toBe("SECRET"); + }); + + test("builds secret interpolation references", () => { + expect(secretReference("X_API_KEY")) + .toBe("{{ secrets.X_API_KEY }}"); + }); +}); diff --git a/apps/fabro-web/app/lib/credential-heuristics.ts b/apps/fabro-web/app/lib/credential-heuristics.ts new file mode 100644 index 000000000..a50fef3c1 --- /dev/null +++ b/apps/fabro-web/app/lib/credential-heuristics.ts @@ -0,0 +1,32 @@ +const TEMPLATE_REFERENCE_PATTERN = /\{\{\s*(secrets|env|vars)\./i; +const CREDENTIAL_KEY_PATTERN = /authorization|password|passwd|secret|token|api[-_]?key|_(key|token|secret)$/i; + +// True when a key/value pair looks credential-bearing and should be nudged +// toward a secret. Never flags an already-templated value. +export function looksLikeCredential(key: string, value: string): boolean { + if (value === "" || TEMPLATE_REFERENCE_PATTERN.test(value)) return false; + if (CREDENTIAL_KEY_PATTERN.test(key)) return true; + return looksHighEntropy(value); +} + +function looksHighEntropy(value: string): boolean { + if (value.length < 20 || /\s/.test(value)) return false; + const classes = [/[a-z]/.test(value), /[A-Z]/.test(value), /\d/.test(value)] + .filter(Boolean).length; + return classes >= 2; +} + +// Suggest a secret name derived from the key (UPPER_SNAKE_CASE, alnum + _). +export function secretNameForKey(key: string): string { + const name = key + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + return name || "SECRET"; +} + +// The interpolation reference to store in place of a literal secret value. +export function secretReference(name: string): string { + return `{{ secrets.${name} }}`; +} diff --git a/apps/fabro-web/app/lib/mcp-server-cache.ts b/apps/fabro-web/app/lib/mcp-server-cache.ts new file mode 100644 index 000000000..06fc79a72 --- /dev/null +++ b/apps/fabro-web/app/lib/mcp-server-cache.ts @@ -0,0 +1,31 @@ +import type { McpServer, McpServerListResponse } from "@qltysh/fabro-api-client"; + +export function upsertMcpServerInList( + current: McpServerListResponse | undefined, + server: McpServer, +): McpServerListResponse | undefined { + if (!current) return current; + const index = current.data.findIndex((item) => item.id === server.id); + const data = + index === -1 + ? [...current.data, server] + : current.data.map((item, i) => (i === index ? server : item)); + return { + ...current, + data, + meta: { ...current.meta, total: data.length }, + }; +} + +export function removeMcpServerFromList( + current: McpServerListResponse | undefined, + id: string, +): McpServerListResponse | undefined { + if (!current) return current; + const data = current.data.filter((server) => server.id !== id); + return { + ...current, + data, + meta: { ...current.meta, total: data.length }, + }; +} diff --git a/apps/fabro-web/app/lib/mcp-transport-kinds.ts b/apps/fabro-web/app/lib/mcp-transport-kinds.ts new file mode 100644 index 000000000..e30d1832e --- /dev/null +++ b/apps/fabro-web/app/lib/mcp-transport-kinds.ts @@ -0,0 +1,7 @@ +export const MCP_TRANSPORT_KINDS = ["stdio", "http", "sandbox"] as const; + +export type McpTransportKind = (typeof MCP_TRANSPORT_KINDS)[number]; + +export function parseMcpTransportKind(value: string | null): McpTransportKind { + return value === "http" || value === "sandbox" ? value : "stdio"; +} diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 82818e66e..3c8eb8a1d 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -13,6 +13,8 @@ import type { EventEnvelope, ListRunsDirectionEnum, ListRunsSortEnum, + McpServer, + McpServerListResponse, Model, PaginatedRunCommitList, PaginatedRunFileList, @@ -53,6 +55,7 @@ import { generatedAxios, humanInTheLoopApi, insightsApi, + mcpServersApi, modelsApi, runInternalsApi, runOutputsApi, @@ -444,6 +447,20 @@ export function useEnvironment(id: string | undefined) { ); } +export function useMcpServers() { + return useSWR( + queryKeys.mcpServers.list(), + () => apiData(() => mcpServersApi.listMcpServers()), + ); +} + +export function useMcpServer(id: string | undefined) { + return useSWR( + id ? queryKeys.mcpServers.detail(id) : null, + id ? () => apiNullableData(() => mcpServersApi.retrieveMcpServer(id)) : null, + ); +} + export function useWorkflows() { return useSWR( queryKeys.workflows.list(), diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts index 65ab39328..dd87ac110 100644 --- a/apps/fabro-web/app/lib/query-keys.test.ts +++ b/apps/fabro-web/app/lib/query-keys.test.ts @@ -51,6 +51,12 @@ describe("queryKeys", () => { ]); expect(queryKeys.runs.sandbox("run 1")).toEqual(["runs", "sandbox", "run 1"]); expect(queryKeys.system.integrations()).toEqual(["system", "integrations"]); + expect(queryKeys.mcpServers.list()).toEqual(["mcp-servers", "list"]); + expect(queryKeys.mcpServers.detail("github")).toEqual([ + "mcp-servers", + "detail", + "github", + ]); expect(queryKeys.system.attachUrl()).toBe("/api/v1/attach"); expect(queryKeys.runs.attachUrl("run 1")).toBe("/api/v1/runs/run%201/attach"); }); diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 23f4fd845..168fc4469 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -121,4 +121,8 @@ export const queryKeys = { list: () => ["environments", "list"] as const, detail: (id: string) => ["environments", "detail", id] as const, }, + mcpServers: { + list: () => ["mcp-servers", "list"] as const, + detail: (id: string) => ["mcp-servers", "detail", id] as const, + }, }; diff --git a/apps/fabro-web/app/router.test.tsx b/apps/fabro-web/app/router.test.tsx index 89def916e..5636c420d 100644 --- a/apps/fabro-web/app/router.test.tsx +++ b/apps/fabro-web/app/router.test.tsx @@ -38,4 +38,12 @@ describe("browser router", () => { expect(paths).toContain("/settings/monitoring"); }); + + test("exposes MCP server settings pages", () => { + const paths = collectPaths(routes); + + expect(paths).toContain("/settings/mcps"); + expect(paths).toContain("/settings/mcps/new"); + expect(paths).toContain("/settings/mcps/:id/edit"); + }); }); diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 08cc46ab3..ea2a19f6c 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -41,6 +41,9 @@ import * as SettingsSandboxes from "./routes/settings-sandboxes"; import * as SettingsEnvironments from "./routes/settings-environments"; import * as SettingsEnvironmentsNew from "./routes/settings-environments-new"; import * as SettingsEnvironmentsEdit from "./routes/settings-environments-edit"; +import * as SettingsMcps from "./routes/settings-mcps"; +import * as SettingsMcpsNew from "./routes/settings-mcps-new"; +import * as SettingsMcpsEdit from "./routes/settings-mcps-edit"; import * as SettingsSecrets from "./routes/settings-secrets"; import * as SettingsSecretsNew from "./routes/settings-secrets-new"; import * as SettingsVariables from "./routes/settings-variables"; @@ -154,6 +157,9 @@ export const routes: RouteObject[] = [ route("environments", SettingsEnvironments), route("environments/new", SettingsEnvironmentsNew), route("environments/:id/edit", SettingsEnvironmentsEdit), + route("mcps", SettingsMcps), + route("mcps/new", SettingsMcpsNew), + route("mcps/:id/edit", SettingsMcpsEdit), route("variables", SettingsVariables), route("variables/new", SettingsVariablesNew), route("variables/:name/edit", SettingsVariablesEdit), diff --git a/apps/fabro-web/app/routes/settings-mcps-edit.test.tsx b/apps/fabro-web/app/routes/settings-mcps-edit.test.tsx new file mode 100644 index 000000000..caa5e195a --- /dev/null +++ b/apps/fabro-web/app/routes/settings-mcps-edit.test.tsx @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { McpServer } from "@qltysh/fabro-api-client"; +import TestRenderer, { act } from "react-test-renderer"; +import { MemoryRouter, Route, Routes } from "react-router"; + +import { setupReactTestEnv } from "../lib/test-utils"; + +let mcpServer: McpServer | null | undefined; +let teardownReactTestEnv: (() => void) | undefined; + +mock.module("../lib/queries", () => ({ + useMcpServer: () => ({ data: mcpServer }), +})); + +const { default: SettingsMcpsEdit } = await import("./settings-mcps-edit"); + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function renderSettingsMcpsEdit() { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create( + + + } /> + + , + ); + }); + mountedRenderers.push(renderer!); + return renderer!; +} + +function textContent(node: ReturnType): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textContent).join(""); + return node.children?.map(textContent).join("") ?? ""; +} + +describe("SettingsMcpsEdit route", () => { + beforeEach(() => { + teardownReactTestEnv = setupReactTestEnv(); + }); + + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + mcpServer = undefined; + teardownReactTestEnv?.(); + teardownReactTestEnv = undefined; + }); + + test("renders the write-only value banner and blank existing env values", () => { + mcpServer = { + id: "github", + revision: "rev-1", + display_name: "GitHub MCP", + description: "GitHub tools", + startup_timeout_secs: 10, + tool_timeout_secs: 60, + transport: { + type: "stdio", + command: ["npx", "server"], + env_keys: ["GITHUB_TOKEN"], + }, + }; + + const renderer = renderSettingsMcpsEdit(); + const text = textContent(renderer.toJSON()); + + expect(text).toContain("Existing environment variable and header values are write-only"); + + const keyInputs = renderer.root.findAllByProps({ "aria-label": "Key" }); + const valueInputs = renderer.root.findAllByProps({ "aria-label": "Value" }); + expect(keyInputs.map((input) => input.props.value)).toContain("GITHUB_TOKEN"); + expect(valueInputs.map((input) => input.props.value)).toContain(""); + }); +}); diff --git a/apps/fabro-web/app/routes/settings-mcps-edit.tsx b/apps/fabro-web/app/routes/settings-mcps-edit.tsx new file mode 100644 index 000000000..e52e0bd87 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-mcps-edit.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router"; +import { useSWRConfig } from "swr"; +import { ChevronRightIcon } from "@heroicons/react/20/solid"; +import type { McpServer } from "@qltysh/fabro-api-client"; + +import { + McpServerFormFields, + isMcpServerFormValid, + mcpServerToFormValues, + replaceRequestFromForm, + type McpServerFormValues, +} from "../components/mcp-server-form"; +import { Panel, PanelSkeleton } from "../components/settings-panel"; +import { + ErrorMessage, + PRIMARY_BUTTON_CLASS, + SECONDARY_BUTTON_CLASS, +} from "../components/ui"; +import { useToast } from "../components/toast"; +import { ApiError, apiData, mcpServersApi } from "../lib/api-client"; +import { upsertMcpServerInList } from "../lib/mcp-server-cache"; +import { queryKeys } from "../lib/query-keys"; +import { useMcpServer } from "../lib/queries"; + +export function meta() { + return [{ title: "Edit MCP server — Fabro" }]; +} + +export default function SettingsMcpsEdit() { + const { id } = useParams<{ id: string }>(); + const query = useMcpServer(id); + + return ( +
+ + {query.data ? ( + + ) : query.error || query.data === null ? ( + +
+ Couldn't load this MCP server. It may have been deleted. +
+
+ ) : ( + + )} +
+ ); +} + +function PageHeader({ id }: { id: string }) { + return ( + + ); +} + +function EditMcpServerForm({ server }: { server: McpServer }) { + const navigate = useNavigate(); + const { mutate } = useSWRConfig(); + const toast = useToast(); + const [values, setValues] = useState(() => + mcpServerToFormValues(server), + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const canSubmit = isMcpServerFormValid(values, { isEdit: true }) && !submitting; + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + const updated = await apiData(() => + mcpServersApi.replaceMcpServer( + server.id, + server.revision, + replaceRequestFromForm(values), + ), + ); + await Promise.all([ + mutate( + queryKeys.mcpServers.list(), + (current) => upsertMcpServerInList(current, updated), + { revalidate: false }, + ), + mutate(queryKeys.mcpServers.detail(server.id), updated, { revalidate: false }), + ]); + toast.push({ message: `MCP server “${server.id}” updated.` }); + navigate("/settings/mcps"); + void mutate(queryKeys.mcpServers.list()); + void mutate(queryKeys.mcpServers.detail(server.id)); + } catch (cause) { + setError(staleAwareMessage(cause)); + setSubmitting(false); + } + } + + return ( +
+ {hasWriteOnlyValues(server) ? : null} + + + + {error ? : null} + +
+ + +
+ + ); +} + +function WriteOnlyValuesBanner() { + return ( +
+ Existing environment variable and header values are write-only and are not shown. Saving + replaces the full set — re-enter every value you want to keep. +
+ ); +} + +function hasWriteOnlyValues(server: McpServer): boolean { + switch (server.transport.type) { + case "stdio": + return server.transport.env_keys.length > 0; + case "http": + return server.transport.header_keys.length > 0; + case "sandbox": + return server.transport.env_keys.length > 0; + } +} + +function staleAwareMessage(cause: unknown): string { + if (cause instanceof ApiError && cause.status === 409) { + return "This MCP server changed since you opened it. Reload the page to get the latest version, then reapply your edits."; + } + if (cause instanceof ApiError && cause.message) { + return cause.message; + } + return "Couldn't update the MCP server. Please try again."; +} diff --git a/apps/fabro-web/app/routes/settings-mcps-new.test.tsx b/apps/fabro-web/app/routes/settings-mcps-new.test.tsx new file mode 100644 index 000000000..553347939 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-mcps-new.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; +import { MemoryRouter } from "react-router"; + +import { setupReactTestEnv } from "../lib/test-utils"; + +const { default: SettingsMcpsNew } = await import("./settings-mcps-new"); + +let teardownReactTestEnv: (() => void) | undefined; +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function renderSettingsMcpsNew(initialEntry = "/settings/mcps/new") { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create( + + + , + ); + }); + mountedRenderers.push(renderer!); + return renderer!; +} + +function textContent(node: ReturnType): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textContent).join(""); + return node.children?.map(textContent).join("") ?? ""; +} + +describe("SettingsMcpsNew route", () => { + beforeEach(() => { + teardownReactTestEnv = setupReactTestEnv(); + }); + + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + teardownReactTestEnv?.(); + teardownReactTestEnv = undefined; + }); + + test("renders the create form for the requested transport", () => { + const renderer = renderSettingsMcpsNew("/settings/mcps/new?type=http"); + const text = textContent(renderer.toJSON()); + + expect(text).toContain("New MCP server"); + expect(renderer.root.findByProps({ "aria-label": "Transport" }).props.value) + .toBe("http"); + expect(renderer.root.findByProps({ "aria-label": "URL" })).toBeDefined(); + }); +}); diff --git a/apps/fabro-web/app/routes/settings-mcps-new.tsx b/apps/fabro-web/app/routes/settings-mcps-new.tsx new file mode 100644 index 000000000..949c6cf73 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-mcps-new.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router"; +import { useSWRConfig } from "swr"; +import { ChevronRightIcon } from "@heroicons/react/20/solid"; + +import { + McpServerFormFields, + createRequestFromForm, + defaultMcpServerFormValues, + isMcpServerFormValid, + type McpServerFormValues, +} from "../components/mcp-server-form"; +import { + ErrorMessage, + PRIMARY_BUTTON_CLASS, + SECONDARY_BUTTON_CLASS, +} from "../components/ui"; +import { useToast } from "../components/toast"; +import { ApiError, apiData, mcpServersApi } from "../lib/api-client"; +import { upsertMcpServerInList } from "../lib/mcp-server-cache"; +import { parseMcpTransportKind } from "../lib/mcp-transport-kinds"; +import { queryKeys } from "../lib/query-keys"; + +export function meta() { + return [{ title: "New MCP server — Fabro" }]; +} + +export default function SettingsMcpsNew() { + return ( +
+ + +
+ ); +} + +function PageHeader() { + return ( + + ); +} + +function CreateMcpServerForm() { + const navigate = useNavigate(); + const { mutate } = useSWRConfig(); + const toast = useToast(); + const [searchParams] = useSearchParams(); + const [values, setValues] = useState(() => + defaultMcpServerFormValues(parseMcpTransportKind(searchParams.get("type"))), + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const canSubmit = isMcpServerFormValid(values, { isEdit: false }) && !submitting; + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + setError(null); + const id = values.id.trim(); + try { + const created = await apiData(() => + mcpServersApi.createMcpServer(createRequestFromForm(values)), + ); + await mutate( + queryKeys.mcpServers.list(), + (current) => upsertMcpServerInList(current, created), + { revalidate: false }, + ); + toast.push({ message: `MCP server “${id}” created.` }); + navigate("/settings/mcps"); + void mutate(queryKeys.mcpServers.list()); + } catch (cause) { + setError( + cause instanceof ApiError && cause.message + ? cause.message + : "Couldn't create the MCP server. Please try again.", + ); + setSubmitting(false); + } + } + + return ( +
+ + + {error ? : null} + +
+ + +
+ + ); +} diff --git a/apps/fabro-web/app/routes/settings-mcps.test.tsx b/apps/fabro-web/app/routes/settings-mcps.test.tsx new file mode 100644 index 000000000..c7b030154 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-mcps.test.tsx @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { McpServerListResponse } from "@qltysh/fabro-api-client"; +import TestRenderer, { act } from "react-test-renderer"; +import { MemoryRouter } from "react-router"; + +import { setupReactTestEnv } from "../lib/test-utils"; + +let mcpServers: McpServerListResponse | undefined; +let teardownReactTestEnv: (() => void) | undefined; + +mock.module("../lib/queries", () => ({ + useMcpServers: () => ({ data: mcpServers }), +})); + +const { default: SettingsMcps } = await import("./settings-mcps"); + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function renderSettingsMcps() { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create( + + + , + ); + }); + mountedRenderers.push(renderer!); + return renderer!; +} + +function textContent(node: ReturnType): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textContent).join(""); + return node.children?.map(textContent).join("") ?? ""; +} + +describe("SettingsMcps route", () => { + beforeEach(() => { + teardownReactTestEnv = setupReactTestEnv(); + }); + + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + mcpServers = undefined; + teardownReactTestEnv?.(); + teardownReactTestEnv = undefined; + }); + + test("renders MCP server rows", () => { + mcpServers = { + data: [ + { + id: "github", + revision: "rev-1", + display_name: "GitHub MCP", + description: "GitHub tools", + startup_timeout_secs: 10, + tool_timeout_secs: 60, + transport: { + type: "http", + url: "https://example.com/mcp", + header_keys: ["Authorization"], + }, + }, + { + id: "filesystem", + revision: "rev-2", + display_name: "Filesystem MCP", + description: null, + startup_timeout_secs: 10, + tool_timeout_secs: 60, + transport: { + type: "stdio", + command: ["npx", "server"], + env_keys: [], + }, + }, + ], + meta: { total: 2 }, + }; + + const renderer = renderSettingsMcps(); + const text = textContent(renderer.toJSON()); + + expect(text).toContain("GitHub MCP"); + expect(text).toContain("github"); + expect(text).toContain("http"); + expect(text).toContain("Filesystem MCP"); + expect(text).toContain("stdio"); + }); + + test("renders an empty state", () => { + mcpServers = { data: [], meta: { total: 0 } }; + + const renderer = renderSettingsMcps(); + const text = textContent(renderer.toJSON()); + + expect(text).toContain("No MCP servers defined yet."); + }); +}); diff --git a/apps/fabro-web/app/routes/settings-mcps.tsx b/apps/fabro-web/app/routes/settings-mcps.tsx new file mode 100644 index 000000000..6b7a1fc81 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-mcps.tsx @@ -0,0 +1,265 @@ +import { useState } from "react"; +import { Link } from "react-router"; +import { useSWRConfig } from "swr"; +import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; +import { ChevronDownIcon, PlusIcon } from "@heroicons/react/16/solid"; +import { EllipsisVerticalIcon } from "@heroicons/react/20/solid"; +import type { McpServer } from "@qltysh/fabro-api-client"; + +import { + Badge, + Muted, + Panel, + PanelSkeleton, + SettingsPageIntro, +} from "../components/settings-panel"; +import { ConfirmDialog } from "../components/ui"; +import { useToast } from "../components/toast"; +import { ApiError, apiData, mcpServersApi } from "../lib/api-client"; +import { removeMcpServerFromList } from "../lib/mcp-server-cache"; +import { MCP_TRANSPORT_KINDS, type McpTransportKind } from "../lib/mcp-transport-kinds"; +import { queryKeys } from "../lib/query-keys"; +import { useMcpServers } from "../lib/queries"; + +const MENU_ITEM_CLASS = + "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"; + +const MENU_ITEM_DANGER_CLASS = + "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"; + +const NEW_BUTTON_CLASS = + "inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-sm font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-line disabled:hover:bg-panel/80 disabled:hover:text-fg-3"; + +const DESCRIPTION = + "MCP servers are server-managed tool providers stored on this Fabro server. Workflows can enable a stored server by name without embedding connection details in each run."; + +export function meta() { + return [{ title: "MCP servers — Fabro" }]; +} + +export default function SettingsMcps() { + const query = useMcpServers(); + + return ( +
+ } /> + {query.data ? ( + + ) : query.error ? ( + +
+ Couldn't load MCP servers. Please try again. +
+
+ ) : ( + + )} +
+ ); +} + +function NewMcpServerMenu() { + return ( + + + + + {MCP_TRANSPORT_KINDS.map((kind) => ( + + + {transportLabel(kind)} + + + ))} + + + ); +} + +function McpServersPanel({ servers }: { servers: McpServer[] }) { + const { mutate } = useSWRConfig(); + const toast = useToast(); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + + async function confirmDelete() { + if (!pendingDelete) return; + const target = pendingDelete; + setDeleting(true); + try { + await apiData(() => mcpServersApi.deleteMcpServer(target.id, target.revision)); + await mutate( + queryKeys.mcpServers.list(), + (current) => removeMcpServerFromList(current, target.id), + { revalidate: false }, + ); + toast.push({ message: `MCP server “${target.id}” deleted.` }); + setPendingDelete(null); + void mutate(queryKeys.mcpServers.list()); + } catch (cause) { + if (cause instanceof ApiError && cause.status === 409) { + await mutate(queryKeys.mcpServers.list()); + toast.push({ + tone: "error", + message: "This MCP server changed before it could be deleted. Refresh and try again.", + }); + } else { + toast.push({ + tone: "error", + message: + cause instanceof ApiError && cause.message + ? cause.message + : "Couldn't delete the MCP server. Please try again.", + }); + } + } finally { + setDeleting(false); + } + } + + return ( + <> + + {servers.length === 0 ? ( +
+ No MCP servers defined yet. +
+ ) : ( + servers.map((server) => ( + setPendingDelete(server)} + /> + )) + )} +
+ + Delete {pendingDelete?.id}? Workflows + that enable this server will fail until it is recreated. + + } + confirmLabel="Delete" + pendingLabel="Deleting…" + pending={deleting} + onConfirm={confirmDelete} + onCancel={() => { + if (!deleting) setPendingDelete(null); + }} + /> + + ); +} + +function McpServerRow({ + server, + disabled, + onDelete, +}: { + server: McpServer; + disabled: boolean; + onDelete: () => void; +}) { + const summary = transportSummary(server); + return ( +
+
+
+ + {server.display_name} + + {server.transport.type} +
+
+ {server.id} + {server.description ? {server.description} : null} +
+
+
+ {summary ?? No transport details} +
+ +
+ ); +} + +function transportSummary(server: McpServer): string | null { + switch (server.transport.type) { + case "stdio": + return server.transport.command.join(" ") || null; + case "http": + return server.transport.url; + case "sandbox": { + const command = server.transport.command.join(" "); + return command ? `${command} · port ${server.transport.port}` : `port ${server.transport.port}`; + } + } +} + +function transportLabel(kind: McpTransportKind): string { + return kind.charAt(0).toUpperCase() + kind.slice(1); +} + +function RowMenu({ + server, + disabled, + onDelete, +}: { + server: McpServer; + disabled: boolean; + onDelete: () => void; +}) { + return ( + + + + + + + Edit + + +
+ + + +
+
+ ); +} diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index 7a803567d..08927d5a5 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -73,6 +73,13 @@ export const navSections: NavSection[] = [ description: "Server-managed runtime definitions for runs.", match: (p) => p.startsWith("/settings/environments"), }, + { + name: "MCP servers", + href: "/settings/mcps", + icon: PuzzlePieceIcon, + description: "Server-managed MCP servers you can enable by name in workflows.", + match: (p) => p.startsWith("/settings/mcps"), + }, { name: "Variables", href: "/settings/variables",