fabro/apps/fabro-web/app/components/mcp-server-form.tsx
fabro-sh-fabro[bot] c945fb404b
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
feat: add MCP servers settings UI at /settings/mcps (#540)
## Summary

Adds a full CRUD management UI for server-managed MCP servers at
`/settings/mcps`, consuming the already-shipped `MCPServersApi` backend.
The implementation mirrors the existing `/settings/environments` pages
exactly in structure, naming, and component conventions.

## What changed

### Step 1 — Shared `KeyValueEditor` extracted
`KeyValueEditor`, `KeyValueEntry`, `entriesFromMap`, and
`mapFromEntries` are moved from `environment-form.tsx` into a new
`components/key-value-editor.tsx`. The component gains an optional
`renderEntryHint` prop so per-row warnings can be injected without
coupling the editor to credential logic. `Label` is promoted from
`environment-form.tsx` to `settings-panel.tsx` so both forms can use it.

### Step 2–4 — Query plumbing
- `query-keys.ts`: `mcpServers.{list, detail}` keys.
- `api-client.ts`: `mcpServersApi` instance (same pattern as
`environmentsApi`).
- `queries.ts`: `useMcpServers()` and `useMcpServer(id)` SWR hooks.

### Step 5 — Credential heuristics (`lib/credential-heuristics.ts`)
Pure functions `looksLikeCredential`, `secretNameForKey`,
`secretReference`. Key-name matching covers `authorization`, `password`,
`token`, `api[-_]?key`, `_key`/`_token`/`_secret` suffixes.
Value-entropy fallback fires for strings ≥ 20 chars, no spaces, mixed
case/digit classes. Template references (`{{ secrets.* }}`) are never
flagged.

### Step 6–7 — Form model + component (`components/mcp-server-form.tsx`)
- Flat `McpServerFormValues` discriminated on `McpTransportKind`.
- `defaultMcpServerFormValues`, `mcpServerToFormValues` (populates
`env`/`headers` from `env_keys`/`header_keys` with **empty values** —
the §5 write-only design), `createRequestFromForm`,
`replaceRequestFromForm`, `isMcpServerFormValid`, `credentialWarnings`.
- `McpServerFormFields` renders stdio / http / sandbox panels switching
on `values.transport`. Per-row credential nudge opens the secrets-new
page in a new tab and substitutes a `{{ secrets.NAME }}` reference; save
is never blocked by the heuristic.
- On edit, a row with a non-empty key and empty value blocks save with
an inline error (the intentional overwrite guard).

### Step 8–10 — Route pages
| File | Mirrors |
|---|---|
| `routes/settings-mcps.tsx` | `settings-environments.tsx` |
| `routes/settings-mcps-new.tsx` | `settings-environments-new.tsx` |
| `routes/settings-mcps-edit.tsx` | `settings-environments-edit.tsx` |

The edit page shows a write-only-values banner whenever the transport
has any `env_keys`/`header_keys`, uses `key={server.revision}` to
remount the form on external change, and translates 409 responses into
the `staleAwareMessage` pattern.

### Steps 11–12 — Router + nav
Three routes registered under `settings` children. `PuzzlePieceIcon` nav
entry added to the same section as Environments.

### Plan Summary
- Extract `KeyValueEditor` to shared component with hint-injection slot
- Credential heuristics library (pure, fully unit-tested)
- MCP form model: flat values ↔ discriminated API types, write-only-key
guard
- List / new / edit pages following environments pattern exactly
- Route registration and settings nav link


### Fabro Details

<details>
<summary>Ran 9 stages in 65m 58s for $20.54</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 47s | – | 0 |
| preflight_lint | 4m 15s | – | 0 |
| implement | 26m 29s | $12.27 | 0 |
| simplify_opus | 7m 41s | $4.95 | 0 |
| simplify_gpt | 6m 51s | $2.69 | 0 |
| verify | 15m 44s | – | 0 |
| fixup | 1m 41s | $0.63 | 0 |
| **Total** | **65m 58s** | **$20.54** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-8; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
2026-07-01 10:32:37 -04:00

564 lines
16 KiB
TypeScript

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<McpServerFormValues>) {
onChange({ ...values, ...partial });
}
const idValid = MCP_SERVER_ID_PATTERN.test(values.id.trim());
return (
<>
<Panel title="General">
<Row
title={<Label required>ID</Label>}
help="Lowercase identifier (letters, digits, hyphens). Workflows enable this MCP server by id. Cannot be changed after creation."
>
{lockId ? (
<div className="font-mono text-sm text-fg">{values.id}</div>
) : (
<input
type="text"
name="id"
aria-label="MCP server ID"
value={values.id}
onChange={(e) => patch({ id: e.target.value })}
placeholder="github"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
)}
</Row>
<Row title={<Label required>Display name</Label>} help="Human-readable name shown in this settings catalog.">
<input
type="text"
name="display_name"
aria-label="Display name"
value={values.displayName}
onChange={(e) => patch({ displayName: e.target.value })}
placeholder="GitHub MCP"
autoComplete="off"
spellCheck={false}
className={INPUT_CLASS}
/>
</Row>
<Row title={<Label optional>Description</Label>} help="Optional note to help operators recognize this server.">
<input
type="text"
name="description"
aria-label="Description"
value={values.description}
onChange={(e) => patch({ description: e.target.value })}
className={INPUT_CLASS}
/>
</Row>
<Row
title={<Label required>Transport</Label>}
help="Choose how Fabro connects to this MCP server. The transport is fixed after creation."
>
{lockTransport ? (
<Badge>{values.transport}</Badge>
) : (
<select
name="transport"
aria-label="Transport"
value={values.transport}
onChange={(e) => patch({ transport: parseMcpTransportKind(e.target.value) })}
className={INPUT_CLASS}
>
{MCP_TRANSPORT_KINDS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
)}
</Row>
<Row title="Startup timeout" help="Seconds to wait for the MCP server to become ready.">
<input
type="number"
name="startup_timeout_secs"
aria-label="Startup timeout"
min={1}
value={values.startupTimeoutSecs}
onChange={(e) => patch({ startupTimeoutSecs: Number(e.target.value) })}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row title="Tool timeout" help="Seconds to allow each MCP tool call before timing out.">
<input
type="number"
name="tool_timeout_secs"
aria-label="Tool timeout"
min={1}
value={values.toolTimeoutSecs}
onChange={(e) => patch({ toolTimeoutSecs: Number(e.target.value) })}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
</Panel>
<Panel title="Transport">
{values.transport === "stdio" ? (
<StdioTransportFields values={values} patch={patch} isEdit={isEdit} />
) : values.transport === "http" ? (
<HttpTransportFields values={values} patch={patch} isEdit={isEdit} />
) : (
<SandboxTransportFields values={values} patch={patch} isEdit={isEdit} />
)}
</Panel>
{!lockId && values.id.trim() !== "" && !idValid ? (
<p className="text-xs text-coral">
ID must be lowercase letters, digits, or hyphens and start with a letter or digit.
</p>
) : null}
</>
);
}
function StdioTransportFields({ values, patch, isEdit }: TransportFieldsProps) {
return (
<>
<CommandRow
values={values}
patch={patch}
help="Command and arguments used to launch the MCP server."
placeholder="npx -y @modelcontextprotocol/server-github"
/>
<KeyValueRows
field="env"
entries={values.env}
onChange={(env) => patch({ env })}
isEdit={isEdit}
/>
</>
);
}
function HttpTransportFields({ values, patch, isEdit }: TransportFieldsProps) {
return (
<>
<ProtocolRow values={values} patch={patch} />
<Row title={<Label required>URL</Label>} help="Remote MCP endpoint URL.">
<input
type="text"
name="url"
aria-label="URL"
value={values.url}
onChange={(e) => patch({ url: e.target.value })}
placeholder="https://example.com/mcp"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<KeyValueRows
field="headers"
entries={values.headers}
onChange={(headers) => patch({ headers })}
isEdit={isEdit}
/>
</>
);
}
function SandboxTransportFields({ values, patch, isEdit }: TransportFieldsProps) {
return (
<>
<ProtocolRow values={values} patch={patch} />
<CommandRow
values={values}
patch={patch}
help="Command and arguments used to launch the MCP server inside the run sandbox."
placeholder="python server.py"
/>
<Row title={<Label required>Port</Label>} help="Port where the in-sandbox MCP server listens.">
<input
type="number"
name="port"
aria-label="Port"
min={1}
max={65_535}
value={values.port}
onChange={(e) => patch({ port: Number(e.target.value) })}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<KeyValueRows
field="env"
entries={values.env}
onChange={(env) => patch({ env })}
isEdit={isEdit}
/>
</>
);
}
interface TransportFieldsProps {
values: McpServerFormValues;
patch: (partial: Partial<McpServerFormValues>) => void;
isEdit: boolean;
}
function CommandRow({
values,
patch,
help,
placeholder,
}: {
values: McpServerFormValues;
patch: (partial: Partial<McpServerFormValues>) => void;
help: string;
placeholder: string;
}) {
return (
<Row title={<Label required>Command</Label>} help={help}>
<input
type="text"
name="command"
aria-label="Command"
value={values.command}
onChange={(e) => patch({ command: e.target.value })}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
);
}
function ProtocolRow({
values,
patch,
}: {
values: McpServerFormValues;
patch: (partial: Partial<McpServerFormValues>) => void;
}) {
return (
<Row title="Protocol" help="HTTP wire protocol used for the MCP connection.">
<select
name="protocol"
aria-label="Protocol"
value={values.protocol}
onChange={(e) => patch({ protocol: parseProtocol(e.target.value) })}
className={INPUT_CLASS}
>
<option value={McpHttpProtocol.STREAMABLE_HTTP}>streamable_http</option>
<option value={McpHttpProtocol.SSE}>sse</option>
</select>
</Row>
);
}
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 (
<Row
title={isHeaders ? "Headers" : "Environment variables"}
help={isHeaders ? "HTTP headers sent with every request." : "Variables injected into the MCP server process."}
>
<KeyValueEditor
entries={entries}
onChange={onChange}
keyPlaceholder={isHeaders ? "Authorization" : "GITHUB_TOKEN"}
valuePlaceholder={isHeaders ? "Bearer token" : "{{ secrets.GITHUB_TOKEN }}"}
addLabel={isHeaders ? "Add header" : "Add variable"}
renderEntryHint={(entry, index) => (
<EntryHint
entry={entry}
requireWriteOnlyValue={isEdit}
onStoreSecret={(value) =>
onChange(entries.map((e, i) => (i === index ? { ...e, value } : e)))
}
/>
)}
/>
</Row>
);
}
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 (
<div className="ml-0.5 space-y-1 text-xs/5">
{missingWriteOnlyValue ? (
<p className="text-coral">
Enter a value for this existing write-only setting, or remove the row before saving.
</p>
) : null}
{credentialWarning ? (
<p className="text-amber">
This looks like a credential.{" "}
<button
type="button"
onClick={() => {
onStoreSecret(secretReference(secretName));
openSecretCreateTab(secretName);
}}
className="font-medium text-amber underline underline-offset-2 hover:text-fg"
>
Store as secret
</button>
</p>
) : null}
</div>
);
}
function openSecretCreateTab(secretName: string) {
if (typeof window === "undefined") return;
window.open(
`/settings/secrets/new?name=${encodeURIComponent(secretName)}`,
"_blank",
"noopener,noreferrer",
);
}