diff --git a/run.json b/run.json index 581f427ee..52dd3289e 100644 --- a/run.json +++ b/run.json @@ -493,7 +493,7 @@ "kind": "running" }, "status_updated_at": "2026-06-30T19:57:14.360709850Z", - "last_event_at": "2026-06-30T20:31:00.093612967Z", + "last_event_at": "2026-06-30T20:38:45.221361233Z", "pending_control": null, "checkpoints": [ { @@ -777,9 +777,9 @@ } }, { - "seq": 0, + "seq": 370, "checkpoint": { - "timestamp": "2026-06-30T20:31:00.143629641Z", + "timestamp": "2026-06-30T20:31:03.872091908Z", "current_node": "implement", "completed_nodes": [ "start", @@ -790,31 +790,192 @@ ], "node_retries": {}, "context_values": { - "failure_signature": "", - "failure_class": "", - "internal.retry_count.implement": 0, - "thread.toolchain.current_node": "preflight_compile", - "thread.start.current_node": "toolchain", - "thread.preflight_compile.current_node": "preflight_lint", - "internal.retry_count.start": 0, - "graph.rankdir": "LR", - "thread.preflight_lint.current_node": "implement", - "last_response": "Implemented the MCP servers settings UI under `apps/fabro-web/app/` only.\n\nSummary:\n- Added `/settings/mcps`, `/settings/mcps/new`, and `/settings/mcps/:id/edit`.\n- Added MCP list/create/edit pages wi", - "internal.work_dir": "/home/daytona/workspace/fabro", - "internal.thread_id": "preflight_lint", - "internal.run_id": "01KWD1PBZDHNBM1MYZH168FVK0", - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", "internal.retry_count.preflight_compile": 0, - "response.implement": "Implemented the MCP servers settings UI under `apps/fabro-web/app/` only.\n\nSummary:\n- Added `/settings/mcps`, `/settings/mcps/new`, and `/settings/mcps/:id/edit`.\n- Added MCP list/create/edit pages with CRUD API usage, revision/If-Match handling, delete confirmation, stale 409 messaging, and settings nav link.\n- Added shared `KeyValueEditor` extraction and reused it in environments + MCP forms.\n- Added MCP form model helpers, validation, read-model-to-form mapping, write-only value guard, and credential warning/nudge flow.\n- Added MCP query keys, SWR hooks, and `MCPServersApi` wiring.\n- Added tests for credential heuristics, key/value editor, MCP form helpers, list/new/edit route smoke behavior, query keys, and router registration.\n- Did not touch Rust, OpenAPI, generated clients, or docs.\n\nValidation:\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun test` ✅ — 650 pass\n- `cd apps/fabro-web && bun run build` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nThe implementation follows the requested write-only edit behavior: existing env/header keys are shown with empty values, the edit banner explains full replacement, and saving is blocked on edit until every non-empty key has a value.", + "internal.retry_count.start": 0, + "internal.run_id": "01KWD1PBZDHNBM1MYZH168FVK0", + "internal.node_visit_count": 1, + "internal.thread_id": "preflight_lint", + "thread.preflight_compile.current_node": "preflight_lint", "last_stage": "implement", + "internal.retry_count.preflight_lint": 0, + "internal.retry_count.toolchain": 0, + "response.implement": "Implemented the MCP servers settings UI under `apps/fabro-web/app/` only.\n\nSummary:\n- Added `/settings/mcps`, `/settings/mcps/new`, and `/settings/mcps/:id/edit`.\n- Added MCP list/create/edit pages with CRUD API usage, revision/If-Match handling, delete confirmation, stale 409 messaging, and settings nav link.\n- Added shared `KeyValueEditor` extraction and reused it in environments + MCP forms.\n- Added MCP form model helpers, validation, read-model-to-form mapping, write-only value guard, and credential warning/nudge flow.\n- Added MCP query keys, SWR hooks, and `MCPServersApi` wiring.\n- Added tests for credential heuristics, key/value editor, MCP form helpers, list/new/edit route smoke behavior, query keys, and router registration.\n- Did not touch Rust, OpenAPI, generated clients, or docs.\n\nValidation:\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun test` ✅ — 650 pass\n- `cd apps/fabro-web && bun run build` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nThe implementation follows the requested write-only edit behavior: existing env/header keys are shown with empty values, the edit banner explains full replacement, and saving is blocked on edit until every non-empty key has a value.", + "thread.preflight_lint.current_node": "implement", + "thread.toolchain.current_node": "preflight_compile", "current_node": "implement", + "last_response": "Implemented the MCP servers settings UI under `apps/fabro-web/app/` only.\n\nSummary:\n- Added `/settings/mcps`, `/settings/mcps/new`, and `/settings/mcps/:id/edit`.\n- Added MCP list/create/edit pages wi", "graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ", + "graph.rankdir": "LR", "internal.fidelity": "compact", + "internal.retry_count.implement": 0, + "outcome": "succeeded", + "graph.goal": "# Implementation plan: MCP servers settings UI (`/settings/mcps`)\n\n> **For the implement-plan workflow.** This is a self-contained spec for adding a\n> web management UI for server-managed MCP servers. Implement **every** step.\n> Use red/green TDD: write the failing `bun test` first, then the code.\n>\n> **Delivery note (for the human, not the agent):** the workflow's implement step\n> reads \"the plan file referenced in the goal,\" so this file must be visible to\n> the cloned workspace — commit it at e.g. `docs/plans/mcp-settings-ui.md` on the\n> branch the instance clones, or paste its contents into the run goal.\n\n---\n\n## 1. What this task is (and what is already done)\n\nThe **entire backend for server-managed MCP servers is already built, merged, and\nlive on `main`.** That includes the on-disk store, the OpenAPI spec, the HTTP CRUD\nhandlers with credential-omitting reads, and the workflow-side reference resolver\nthat lets a workflow enable a stored server by name. **None of that is in scope\nhere and none of it should be changed.**\n\nThis task is **frontend only**: a settings catalog page at `/settings/mcps` that\nlets a user create, list, edit, and delete MCP server definitions through the\nalready-shipped HTTP API, following the exact patterns used by the existing\n`/settings/environments` and `/settings/secrets` pages.\n\nEverything you build lives under `apps/fabro-web/app/`. You consume the\n**already-generated** TypeScript client `@qltysh/fabro-api-client` (the\n`MCPServersApi` class and its models already exist — do not regenerate it).\n\n---\n\n## 2. Scope\n\n**In scope (all under `apps/fabro-web/app/`):**\n\n- A list page, a \"new\" page, and an \"edit\" page for MCP servers.\n- A shared MCP form component with stdio / http / sandbox transport variants.\n- Key/value editors for environment variables and HTTP headers.\n- A credential-looking-value warning with a \"use a secret instead\" nudge\n (warn-but-allow — never block save).\n- Query hooks + query keys + API client wiring.\n- Route registration and (last) the settings nav link.\n- `bun test` unit tests for every pure helper and a render smoke test per page.\n\n**Out of scope — do NOT touch any of these (they are done or are future work):**\n\n- ❌ Any Rust crate: `fabro-config`, `fabro-types`, `fabro-mcp-store`,\n `fabro-server`, `fabro-workflow`, `fabro-api`, etc. The resolver, store, and\n handlers are complete.\n- ❌ The OpenAPI spec (`docs/public/api-reference/fabro-api.yaml`) and the\n generated clients. `MCPServersApi` is already generated and importable. Do not\n run `bun run generate`, do not edit `lib/packages/fabro-api-client`.\n- ❌ Adding/altering API endpoints or request/response shapes. In particular, do\n **not** invent a PATCH/partial-update endpoint to work around the read model\n omitting env/header values (see §5) — that is deliberately future work.\n- ❌ The reference-resolution behavior, the run-only `fabro exec` rejection, the\n reference TOML syntax, or anything about how runs consume the catalog.\n- ❌ The SQLite migration of the store.\n- ❌ Documentation (`mcp.mdx`) and end-to-end Rust tests — separate task.\n\nIf you find yourself editing a `.rs` file or `fabro-api.yaml`, stop — you have\nleft scope.\n\n---\n\n## 3. Locked decisions — do NOT relitigate\n\nThese are settled. Build to them; don't redesign them.\n\n1. **Credentials: warn, don't block.** When an env-var or header looks\n credential-bearing, show a warning and offer a one-click path to store it as a\n secret and reference it with the secrets name rather than the value. **Still allow the user to\n save.** Do not hard-reject. Ordinary literals (`NODE_ENV=production`, ports,\n feature flags, non-sensitive headers) save without friction.\n2. **Reads never expose secret values.** The API returns only env/header **names**\n (`env_keys` / `header_keys`), never values. The UI must never attempt to\n display a stored value and must not assume it can read one back.\n3. **Settings-nested, not top-level.** This lives under `/settings/mcps`, mirroring\n `/settings/environments` and `/settings/secrets`. It is not a top-level nav\n destination.\n4. **Optimistic concurrency via `revision` + `If-Match`.** Same as environments:\n capture `revision` from the read, pass it to replace/delete, surface a 409 as a\n \"changed since you opened it — reload\" message. No auto-retry.\n5. **No form library, no `useEffect`.** Plain `useState` controlled inputs, pure\n validation functions, SWR for server state, `key={revision}` to reset the edit\n form. This is the house style and the repo enforces an effects policy\n (`docs/internal/react-effects-policy.md`).\n6. **Transport type is chosen at create time and locked on edit** — mirroring how\n environments lock `provider` at creation. (You may revisit later; for v1, lock\n it. The id is likewise locked on edit.)\n\n---\n\n## 4. The API contract you consume\n\nAlready generated in `@qltysh/fabro-api-client`. Methods on `MCPServersApi`:\n\n| Method | Signature | Returns |\n|---|---|---|\n| list | `listMcpServers()` | `McpServerListResponse` (`{ data: McpServer[], meta: { total } }`) |\n| get | `retrieveMcpServer(id)` | `McpServer` |\n| create | `createMcpServer(CreateMcpServerRequest)` | `McpServer` |\n| replace | `replaceMcpServer(id, ifMatch, ReplaceMcpServerRequest)` | `McpServer` |\n| delete | `deleteMcpServer(id, ifMatch)` | `void` |\n\nThe generated client automatically sets the `If-Match` header from the `ifMatch`\nargument.\n\n**Read model — `McpServer` (values omitted):**\n\n```ts\ninterface McpServer {\n id: string; // stable id; also the runtime MCP server name\n revision: string; // pass as If-Match on replace/delete\n display_name: string;\n description: string | null;\n transport: McpTransportView; // union by `type`; VALUES OMITTED\n startup_timeout_secs: number;\n tool_timeout_secs: number;\n}\n\n// McpTransportView = http | sandbox | stdio, each WITHOUT values:\n// stdio: { type: 'stdio', command: string[], env_keys: string[] }\n// http: { type: 'http', protocol?: McpHttpProtocol, url: string, header_keys: string[] }\n// sandbox: { type: 'sandbox', protocol?: McpHttpProtocol, command: string[], port: number, env_keys: string[] }\n```\n\n**Write model — `CreateMcpServerRequest` / `ReplaceMcpServerRequest` (values required):**\n\n```ts\ninterface CreateMcpServerRequest {\n id: string; // create only; on replace the path id is authoritative\n display_name: string;\n description?: string | null;\n transport: McpTransport; // union by `type`; VALUES INCLUDED\n startup_timeout_secs: number;\n tool_timeout_secs: number;\n}\n// ReplaceMcpServerRequest is the same minus `id`.\n\n// McpTransport = http | sandbox | stdio, each WITH values:\n// stdio: { type: 'stdio', command: string[], env: Record }\n// http: { type: 'http', protocol?: McpHttpProtocol, url: string, headers: Record }\n// sandbox: { type: 'sandbox', protocol?: McpHttpProtocol, command: string[], port: number, env: Record }\n\n// McpHttpProtocol = 'streamable_http' | 'sse' (default streamable_http; only on http/sandbox)\n```\n\n`replace` is a **full PUT** (whole transport replaced). There is no merge/preserve.\n\n---\n\n## 5. The one genuinely tricky UX decision: editing when values are write-only\n\nBecause reads return only `env_keys`/`header_keys` and replace is a full PUT, **the\nedit form cannot read back env/header values, and saving overwrites the entire\ntransport.** Handle it explicitly — do not try to fix it server-side:\n\n- On the **edit** form, pre-populate env/header rows with the existing **keys** and\n **empty values**.\n- Show a clear banner on the edit form whenever the transport has any\n `env_keys`/`header_keys`:\n > \"Existing environment variable and header values are write-only and are not\n > shown. Saving replaces the full set — re-enter every value you want to keep.\"\n- On the edit form only, **block save** (with an inline row error) if any row has a\n non-empty key but an empty value, so the user can't silently blank out values.\n (On the create form, empty values are simply allowed/optional per the API.)\n\nThis keeps the destructive-overwrite behavior visible and intentional.\n\n---\n\n## 6. Reference implementation to copy from\n\nMirror these existing files closely (same structure, naming, classes, helpers):\n\n- Routes: `apps/fabro-web/app/router.tsx` (environments entries, ~lines 147–166)\n- Nav: `apps/fabro-web/app/routes/settings.tsx` (`navSections`, `NavItem`/`NavSection`)\n- List: `apps/fabro-web/app/routes/settings-environments.tsx`\n- New: `apps/fabro-web/app/routes/settings-environments-new.tsx`\n- Edit: `apps/fabro-web/app/routes/settings-environments-edit.tsx`\n- Form + key/value editor: `apps/fabro-web/app/components/environment-form.tsx`\n (`EnvironmentFormFields`, `KeyValueEditor`, `entriesFromMap`/`mapFromEntries`)\n- Panels: `apps/fabro-web/app/components/settings-panel.tsx`\n (`Panel`, `Row`, `SettingsPageIntro`, `PanelSkeleton`, `Badge`, `Muted`)\n- Query keys: `apps/fabro-web/app/lib/query-keys.ts` (`environments.{list,detail}`)\n- Query hooks: `apps/fabro-web/app/lib/queries.ts` (`useEnvironments`, `useEnvironment`)\n- API client: `apps/fabro-web/app/lib/api-client.ts` (`environmentsApi`, `apiData`, `apiNullableData`, `ApiError`)\n- Secrets create flow (for the nudge target): `apps/fabro-web/app/routes/settings-secrets-new.tsx` (reads `?name=`)\n- Test convention: `apps/fabro-web/app/routes/settings-integrations.test.tsx`\n (`bun:test`, `react-test-renderer`, `setupReactTestEnv`, `mock.module(\"../lib/queries\", ...)`)\n\n---\n\n## 7. Implementation steps (TDD; do them in this order)\n\nEach step: write the failing test(s) first where a test is called for, then\nimplement until green. Run `cd apps/fabro-web && bun test` and `bun run typecheck`\nfrequently.\n\n### Step 1 — Extract the shared key/value editor\n\n`KeyValueEditor`, the `KeyValueEntry` type, and `entriesFromMap` / `mapFromEntries`\ncurrently live inside `components/environment-form.tsx`. Move them verbatim (no\nbehavior change) into a new `apps/fabro-web/app/components/key-value-editor.tsx`\nand re-import them in `environment-form.tsx`. Both the MCP form and the environment\nform then share one implementation.\n\n- Test: `components/key-value-editor.test.tsx` — `mapFromEntries` drops blank keys\n and trims, `entriesFromMap` round-trips; a render test adds/removes a row.\n- Verify environments still typecheck and any existing environment tests pass.\n\n### Step 2 — Query keys\n\nIn `lib/query-keys.ts`, add alongside `environments`:\n\n```ts\nmcpServers: {\n list: () => [\"mcp-servers\", \"list\"] as const,\n detail: (id: string) => [\"mcp-servers\", \"detail\", id] as const,\n},\n```\n\n### Step 3 — API client wiring\n\nIn `lib/api-client.ts`, import `MCPServersApi` from `@qltysh/fabro-api-client` and\ninstantiate it exactly like `environmentsApi`:\n\n```ts\nexport const mcpServersApi = new MCPServersApi(\n generatedApiConfiguration,\n \"\",\n generatedAxios,\n);\n```\n\n### Step 4 — Query hooks\n\nIn `lib/queries.ts`, mirror the environments hooks:\n\n```ts\nexport function useMcpServers() {\n return useSWR(\n queryKeys.mcpServers.list(),\n () => apiData(() => mcpServersApi.listMcpServers()),\n );\n}\n\nexport function useMcpServer(id: string | undefined) {\n return useSWR(\n id ? queryKeys.mcpServers.detail(id) : null,\n id ? () => apiNullableData(() => mcpServersApi.retrieveMcpServer(id)) : null,\n );\n}\n```\n\n### Step 5 — Credential detection helper (pure, TDD)\n\nNew `apps/fabro-web/app/lib/credential-heuristics.ts`. Pure functions, fully unit\ntested first.\n\n```ts\n// True when a key/value pair looks credential-bearing and should be nudged\n// toward a secret. Never flags an already-templated value.\nexport function looksLikeCredential(key: string, value: string): boolean;\n\n// Suggest a secret name derived from the key (UPPER_SNAKE_CASE, alnum + _).\nexport function secretNameForKey(key: string): string;\n\n// The interpolation reference to store in place of a literal secret value.\nexport function secretReference(name: string): string; // `\\{\\{ secrets.NAME \\}\\}`\n```\n\nRules for `looksLikeCredential`:\n- Return **false** if `value` is empty, or already a template\n (`/\\{\\{\\s*(secrets|env|vars)\\./` matches) — references are fine as-is.\n- Return **true** if the key (case-insensitive) matches any of:\n `authorization`, `password`, `passwd`, `secret`, `token`, `api[-_]?key`,\n or ends in `_key` / `_token` / `_secret`.\n- Otherwise return **true** if the value looks high-entropy: length ≥ 20, contains\n a mix of at least two of {lowercase, uppercase, digit}, and has no spaces.\n- Otherwise **false**.\n\n`secretNameForKey`: uppercase, replace non-`[A-Za-z0-9]` with `_`, collapse\nrepeats, trim leading/trailing `_`; if empty, fall back to `SECRET`.\n\nTests must cover: `Authorization`/`Bearer abc...` → flagged; `API_KEY` → flagged;\n`NODE_ENV`/`production` → not flagged; a `\\{\\{ secrets.X \\}\\}` value → not flagged;\nempty value → not flagged; a long random-looking value under a benign key →\nflagged; `secretNameForKey(\"x-api-key\") === \"X_API_KEY\"`.\n\n### Step 6 — Form model: types, mappers, validation (pure, TDD)\n\nNew `apps/fabro-web/app/components/mcp-server-form.tsx` (form component) plus a\nsmall co-located or `lib/`-level module of pure helpers (tested first):\n\nForm value shape (flat, transport-discriminated):\n\n```ts\nexport type McpTransportKind = \"stdio\" | \"http\" | \"sandbox\";\n\nexport interface McpServerFormValues {\n id: string;\n displayName: string;\n description: string;\n startupTimeoutSecs: number;\n toolTimeoutSecs: number;\n transport: McpTransportKind;\n // stdio + sandbox\n command: string; // shell-style words; split on whitespace -> string[]\n // http + sandbox\n protocol: McpHttpProtocol; // default 'streamable_http'\n // http\n url: string;\n headers: KeyValueEntry[];\n // sandbox\n port: number;\n // stdio + sandbox env\n env: KeyValueEntry[];\n}\n```\n\nPure helpers + tests:\n\n- `defaultMcpServerFormValues(kind): McpServerFormValues` — sensible defaults\n (timeouts mirror `McpServerSettings::default()`: startup 10, tool 60).\n- `mcpServerToFormValues(server: McpServer): McpServerFormValues` — for edit.\n Maps `transport` **view** into the form. **Env/header values are not available**,\n so populate `env`/`headers` entries from `env_keys`/`header_keys` with **empty\n values** (see §5).\n- `createRequestFromForm(values): CreateMcpServerRequest` and\n `replaceRequestFromForm(values): ReplaceMcpServerRequest` — build the\n `McpTransport` (values included), splitting `command` on whitespace, converting\n entry arrays via `mapFromEntries`, omitting `protocol` when default.\n- `isMcpServerFormValid(values, { isEdit }): boolean` — id matches\n `/^[a-z0-9][a-z0-9-]{0,62}$/` (create only), `displayName` non-empty, and per\n transport: stdio needs `command`; http needs `url`; sandbox needs `command` and a\n valid `port` (1–65535). On edit, additionally require a value for every row with\n a non-empty key (the §5 guard).\n- `credentialWarnings(values): { field: 'env'|'headers'; index: number }[]` —\n derived during render from `looksLikeCredential`, used to render per-row warnings.\n\nRound-trip test: `mcpServerToFormValues` → `replaceRequestFromForm` preserves id,\ndisplay name, transport type, url/command/port; and (documented) drops values that\nweren't re-entered.\n\n### Step 7 — The form component\n\n`McpServerFormFields({ values, onChange, lockId, lockTransport })` in\n`components/mcp-server-form.tsx`, built from `Panel` / `Row` and a local `patch()`\nhelper (copy the environment-form idiom):\n\n- **General panel:** `id` (locked on edit), `display_name`, `description`,\n transport `\n+ )}\n+ \n+ \n+ patch({ startupTimeoutSecs: Number(e.target.value) })}\n+ className={`${INPUT_CLASS} font-mono`}\n+ />\n+ \n+ \n+ patch({ toolTimeoutSecs: Number(e.target.value) })}\n+ className={`${INPUT_CLASS} font-mono`}\n+ />\n+ \n+ \n+\n+ \n+ {values.transport === \"stdio\" ? (\n+ \n+ ) : values.transport === \"http\" ? (\n+ \n+ ) : (\n+ \n+ )}\n+ \n+\n+ {!lockId && values.id.trim() !== \"\" && !idValid ? (\n+

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

\n+ ) : null}\n+ \n+ );\n+}\n+\n+function StdioTransportFields({\n+ values,\n+ patch,\n+ requireWriteOnlyValues,\n+ setEntryValue,\n+}: TransportFieldsProps) {\n+ return (\n+ <>\n+ Command} help=\"Command and arguments used to launch the MCP server.\">\n+ patch({ command: e.target.value })}\n+ placeholder=\"npx -y @modelcontextprotocol/server-github\"\n+ autoComplete=\"off\"\n+ spellCheck={false}\n+ className={`${INPUT_CLASS} font-mono`}\n+ />\n+ \n+ patch({ env })}\n+ requireWriteOnlyValues={requireWriteOnlyValues}\n+ setEntryValue={setEntryValue}\n+ />\n+ \n+ );\n+}\n+\n+function HttpTransportFields({\n+ values,\n+ patch,\n+ requireWriteOnlyValues,\n+ setEntryValue,\n+}: TransportFieldsProps) {\n+ return (\n+ <>\n+ \n+ URL} help=\"Remote MCP endpoint URL.\">\n+ patch({ url: e.target.value })}\n+ placeholder=\"https://example.com/mcp\"\n+ autoComplete=\"off\"\n+ spellCheck={false}\n+ className={`${INPUT_CLASS} font-mono`}\n+ />\n+ \n+ patch({ headers })}\n+ requireWriteOnlyValues={requireWriteOnlyValues}\n+ setEntryValue={setEntryValue}\n+ />\n+ \n+ );\n+}\n+\n+function SandboxTransportFields({\n+ values,\n+ patch,\n+ requireWriteOnlyValues,\n+ setEntryValue,\n+}: TransportFieldsProps) {\n+ return (\n+ <>\n+ \n+ Command} help=\"Command and arguments used to launch the MCP server inside the run sandbox.\">\n+ patch({ command: e.target.value })}\n+ placeholder=\"python server.py\"\n+ autoComplete=\"off\"\n+ spellCheck={false}\n+ className={`${INPUT_CLASS} font-mono`}\n+ />\n+ \n+ Port} help=\"Port where the in-sandbox MCP server listens.\">\n+ patch({ port: Number(e.target.value) })}\n+ className={`${INPUT_CLASS} font-mono`}\n+ />\n+ \n+ patch({ env })}\n+ requireWriteOnlyValues={requireWriteOnlyValues}\n+ setEntryValue={setEntryValue}\n+ />\n+ \n+ );\n+}\n+\n+interface TransportFieldsProps {\n+ values: McpServerFormValues;\n+ patch: (partial: Partial) => void;\n+ requireWriteOnlyValues: boolean;\n+ setEntryValue: (field: \"env\" | \"headers\", index: number, value: string) => void;\n+}\n+\n+function ProtocolRow({\n+ values,\n+ patch,\n+}: {\n+ values: McpServerFormValues;\n+ patch: (partial: Partial) => void;\n+}) {\n+ return (\n+ \n+ patch({ protocol: parseProtocol(e.target.value) })}\n+ className={INPUT_CLASS}\n+ >\n+ \n+ \n+ \n+ \n+ );\n+}\n+\n+function parseProtocol(value: string): McpHttpProtocolValue {\n+ return value === McpHttpProtocol.SSE ? McpHttpProtocol.SSE : DEFAULT_PROTOCOL;\n+}\n+\n+function KeyValueRows({\n+ field,\n+ entries,\n+ onChange,\n+ requireWriteOnlyValues,\n+ setEntryValue,\n+}: {\n+ field: \"env\" | \"headers\";\n+ entries: KeyValueEntry[];\n+ onChange: (entries: KeyValueEntry[]) => void;\n+ requireWriteOnlyValues: boolean;\n+ setEntryValue: (field: \"env\" | \"headers\", index: number, value: string) => void;\n+}) {\n+ const isHeaders = field === \"headers\";\n+ return (\n+ \n+ (\n+ \n+ )}\n+ />\n+ \n+ );\n+}\n+\n+function EntryHint({\n+ field,\n+ index,\n+ entry,\n+ requireWriteOnlyValue,\n+ setEntryValue,\n+}: {\n+ field: \"env\" | \"headers\";\n+ index: number;\n+ entry: KeyValueEntry;\n+ requireWriteOnlyValue: boolean;\n+ setEntryValue: (field: \"env\" | \"headers\", index: number, value: string) => void;\n+}) {\n+ const missingWriteOnlyValue = requireWriteOnlyValue && entry.key.trim() !== \"\" && entry.value === \"\";\n+ const credentialWarning = looksLikeCredential(entry.key, entry.value);\n+ if (!missingWriteOnlyValue && !credentialWarning) return null;\n+\n+ const secretName = secretNameForKey(entry.key);\n+ return (\n+
\n+ {missingWriteOnlyValue ? (\n+

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

\n+ ) : null}\n+ {credentialWarning ? (\n+

\n+ This looks like a credential.{\" \"}\n+ {\n+ setEntryValue(field, index, secretReference(secretName));\n+ openSecretCreateTab(secretName);\n+ }}\n+ className=\"font-medium text-amber underline underline-offset-2 hover:text-fg\"\n+ >\n+ Store as secret\n+ \n+

\n+ ) : null}\n+
\n+ );\n+}\n+\n+function openSecretCreateTab(secretName: string) {\n+ if (typeof window === \"undefined\") return;\n+ window.open(\n+ `/settings/secrets/new?name=${encodeURIComponent(secretName)}`,\n+ \"_blank\",\n+ \"noopener,noreferrer\",\n+ );\n+}\n+\n+function Label({\n+ children,\n+ required,\n+ optional,\n+}: {\n+ children: ReactNode;\n+ required?: boolean;\n+ optional?: boolean;\n+}) {\n+ return (\n+ \n+ {children}\n+ {required ? (\n+ \n+ *\n+ \n+ ) : null}\n+ {optional ? Optional : null}\n+ \n+ );\n+}\ndiff --git a/apps/fabro-web/app/lib/api-client.ts b/apps/fabro-web/app/lib/api-client.ts\nindex d06e17e0d..e77eb6d8a 100644\n--- a/apps/fabro-web/app/lib/api-client.ts\n+++ b/apps/fabro-web/app/lib/api-client.ts\n@@ -12,6 +12,7 @@ import {\n HumanInTheLoopApi,\n InsightsApi,\n InstallApi,\n+ MCPServersApi,\n ModelsApi,\n RunInternalsApi,\n RunOutputsApi,\n@@ -97,6 +98,11 @@ export const insightsApi = new InsightsApi(\n \"\",\n generatedAxios,\n );\n+export const mcpServersApi = new MCPServersApi(\n+ generatedApiConfiguration,\n+ \"\",\n+ generatedAxios,\n+);\n export const installApi = new InstallApi(\n generatedApiConfiguration,\n \"\",\ndiff --git a/apps/fabro-web/app/lib/credential-heuristics.test.ts b/apps/fabro-web/app/lib/credential-heuristics.test.ts\nnew file mode 100644\nindex 000000000..3f571c0f8\n--- /dev/null\n+++ b/apps/fabro-web/app/lib/credential-heuristics.test.ts\n@@ -0,0 +1,57 @@\n+import { describe, expect, test } from \"bun:test\";\n+\n+import {\n+ looksLikeCredential,\n+ secretNameForKey,\n+ secretReference,\n+} from \"./credential-heuristics\";\n+\n+describe(\"credential heuristics\", () => {\n+ test(\"flags Authorization bearer values\", () => {\n+ expect(looksLikeCredential(\"Authorization\", \"Bearer abc123\"))\n+ .toBe(true);\n+ });\n+\n+ test(\"flags API key names\", () => {\n+ expect(looksLikeCredential(\"API_KEY\", \"abc\"))\n+ .toBe(true);\n+ expect(looksLikeCredential(\"x-api-key\", \"abc\"))\n+ .toBe(true);\n+ });\n+\n+ test(\"does not flag ordinary environment values\", () => {\n+ expect(looksLikeCredential(\"NODE_ENV\", \"production\"))\n+ .toBe(false);\n+ });\n+\n+ test(\"does not flag already templated references\", () => {\n+ expect(looksLikeCredential(\"API_KEY\", \"{{ secrets.OPENAI_API_KEY }}\"))\n+ .toBe(false);\n+ expect(looksLikeCredential(\"TOKEN\", \"{{ env.GITHUB_TOKEN }}\"))\n+ .toBe(false);\n+ expect(looksLikeCredential(\"PASSWORD\", \"{{ vars.RUNTIME_PASSWORD }}\"))\n+ .toBe(false);\n+ });\n+\n+ test(\"does not flag empty values\", () => {\n+ expect(looksLikeCredential(\"PASSWORD\", \"\"))\n+ .toBe(false);\n+ });\n+\n+ test(\"flags long high-entropy-looking values under benign keys\", () => {\n+ expect(looksLikeCredential(\"session_id\", \"aBcdEf1234567890Ghij\"))\n+ .toBe(true);\n+ });\n+\n+ test(\"derives secret names from keys\", () => {\n+ expect(secretNameForKey(\"x-api-key\"))\n+ .toBe(\"X_API_KEY\");\n+ expect(secretNameForKey(\" \"))\n+ .toBe(\"SECRET\");\n+ });\n+\n+ test(\"builds secret interpolation references\", () => {\n+ expect(secretReference(\"X_API_KEY\"))\n+ .toBe(\"{{ secrets.X_API_KEY }}\");\n+ });\n+});\ndiff --git a/apps/fabro-web/app/lib/credential-heuristics.ts b/apps/fabro-web/app/lib/credential-heuristics.ts\nnew file mode 100644\nindex 000000000..a50fef3c1\n--- /dev/null\n+++ b/apps/fabro-web/app/lib/credential-heuristics.ts\n@@ -0,0 +1,32 @@\n+const TEMPLATE_REFERENCE_PATTERN = /\\{\\{\\s*(secrets|env|vars)\\./i;\n+const CREDENTIAL_KEY_PATTERN = /authorization|password|passwd|secret|token|api[-_]?key|_(key|token|secret)$/i;\n+\n+// True when a key/value pair looks credential-bearing and should be nudged\n+// toward a secret. Never flags an already-templated value.\n+export function looksLikeCredential(key: string, value: string): boolean {\n+ if (value === \"\" || TEMPLATE_REFERENCE_PATTERN.test(value)) return false;\n+ if (CREDENTIAL_KEY_PATTERN.test(key)) return true;\n+ return looksHighEntropy(value);\n+}\n+\n+function looksHighEntropy(value: string): boolean {\n+ if (value.length < 20 || /\\s/.test(value)) return false;\n+ const classes = [/[a-z]/.test(value), /[A-Z]/.test(value), /\\d/.test(value)]\n+ .filter(Boolean).length;\n+ return classes >= 2;\n+}\n+\n+// Suggest a secret name derived from the key (UPPER_SNAKE_CASE, alnum + _).\n+export function secretNameForKey(key: string): string {\n+ const name = key\n+ .toUpperCase()\n+ .replace(/[^A-Z0-9]+/g, \"_\")\n+ .replace(/_+/g, \"_\")\n+ .replace(/^_+|_+$/g, \"\");\n+ return name || \"SECRET\";\n+}\n+\n+// The interpolation reference to store in place of a literal secret value.\n+export function secretReference(name: string): string {\n+ return `{{ secrets.${name} }}`;\n+}\ndiff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts\nindex 82818e66e..3c8eb8a1d 100644\n--- a/apps/fabro-web/app/lib/queries.ts\n+++ b/apps/fabro-web/app/lib/queries.ts\n@@ -13,6 +13,8 @@ import type {\n EventEnvelope,\n ListRunsDirectionEnum,\n ListRunsSortEnum,\n+ McpServer,\n+ McpServerListResponse,\n Model,\n PaginatedRunCommitList,\n PaginatedRunFileList,\n@@ -53,6 +55,7 @@ import {\n generatedAxios,\n humanInTheLoopApi,\n insightsApi,\n+ mcpServersApi,\n modelsApi,\n runInternalsApi,\n runOutputsApi,\n@@ -444,6 +447,20 @@ export function useEnvironment(id: string | undefined) {\n );\n }\n \n+export function useMcpServers() {\n+ return useSWR(\n+ queryKeys.mcpServers.list(),\n+ () => apiData(() => mcpServersApi.listMcpServers()),\n+ );\n+}\n+\n+export function useMcpServer(id: string | undefined) {\n+ return useSWR(\n+ id ? queryKeys.mcpServers.detail(id) : null,\n+ id ? () => apiNullableData(() => mcpServersApi.retrieveMcpServer(id)) : null,\n+ );\n+}\n+\n export function useWorkflows() {\n return useSWR(\n queryKeys.workflows.list(),\ndiff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts\nindex 65ab39328..dd87ac110 100644\n--- a/apps/fabro-web/app/lib/query-keys.test.ts\n+++ b/apps/fabro-web/app/lib/query-keys.test.ts\n@@ -51,6 +51,12 @@ describe(\"queryKeys\", () => {\n ]);\n expect(queryKeys.runs.sandbox(\"run 1\")).toEqual([\"runs\", \"sandbox\", \"run 1\"]);\n expect(queryKeys.system.integrations()).toEqual([\"system\", \"integrations\"]);\n+ expect(queryKeys.mcpServers.list()).toEqual([\"mcp-servers\", \"list\"]);\n+ expect(queryKeys.mcpServers.detail(\"github\")).toEqual([\n+ \"mcp-servers\",\n+ \"detail\",\n+ \"github\",\n+ ]);\n expect(queryKeys.system.attachUrl()).toBe(\"/api/v1/attach\");\n expect(queryKeys.runs.attachUrl(\"run 1\")).toBe(\"/api/v1/runs/run%201/attach\");\n });\ndiff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts\nindex 23f4fd845..168fc4469 100644\n--- a/apps/fabro-web/app/lib/query-keys.ts\n+++ b/apps/fabro-web/app/lib/query-keys.ts\n@@ -121,4 +121,8 @@ export const queryKeys = {\n list: () => [\"environments\", \"list\"] as const,\n detail: (id: string) => [\"environments\", \"detail\", id] as const,\n },\n+ mcpServers: {\n+ list: () => [\"mcp-servers\", \"list\"] as const,\n+ detail: (id: string) => [\"mcp-servers\", \"detail\", id] as const,\n+ },\n };\ndiff --git a/apps/fabro-web/app/router.test.tsx b/apps/fabro-web/app/router.test.tsx\nindex 89def916e..5636c420d 100644\n--- a/apps/fabro-web/app/router.test.tsx\n+++ b/apps/fabro-web/app/router.test.tsx\n@@ -38,4 +38,12 @@ describe(\"browser router\", () => {\n \n expect(paths).toContain(\"/settings/monitoring\");\n });\n+\n+ test(\"exposes MCP server settings pages\", () => {\n+ const paths = collectPaths(routes);\n+\n+ expect(paths).toContain(\"/settings/mcps\");\n+ expect(paths).toContain(\"/settings/mcps/new\");\n+ expect(paths).toContain(\"/settings/mcps/:id/edit\");\n+ });\n });\ndiff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx\nindex 08cc46ab3..ea2a19f6c 100644\n--- a/apps/fabro-web/app/router.tsx\n+++ b/apps/fabro-web/app/router.tsx\n@@ -41,6 +41,9 @@ import * as SettingsSandboxes from \"./routes/settings-sandboxes\";\n import * as SettingsEnvironments from \"./routes/settings-environments\";\n import * as SettingsEnvironmentsNew from \"./routes/settings-environments-new\";\n import * as SettingsEnvironmentsEdit from \"./routes/settings-environments-edit\";\n+import * as SettingsMcps from \"./routes/settings-mcps\";\n+import * as SettingsMcpsNew from \"./routes/settings-mcps-new\";\n+import * as SettingsMcpsEdit from \"./routes/settings-mcps-edit\";\n import * as SettingsSecrets from \"./routes/settings-secrets\";\n import * as SettingsSecretsNew from \"./routes/settings-secrets-new\";\n import * as SettingsVariables from \"./routes/settings-variables\";\n@@ -154,6 +157,9 @@ export const routes: RouteObject[] = [\n route(\"environments\", SettingsEnvironments),\n route(\"environments/new\", SettingsEnvironmentsNew),\n route(\"environments/:id/edit\", SettingsEnvironmentsEdit),\n+ route(\"mcps\", SettingsMcps),\n+ route(\"mcps/new\", SettingsMcpsNew),\n+ route(\"mcps/:id/edit\", SettingsMcpsEdit),\n route(\"variables\", SettingsVariables),\n route(\"variables/new\", SettingsVariablesNew),\n route(\"variables/:name/edit\", SettingsVariablesEdit),\ndiff --git a/apps/fabro-web/app/routes/settings-mcps-edit.test.tsx b/apps/fabro-web/app/routes/settings-mcps-edit.test.tsx\nnew file mode 100644\nindex 000000000..caa5e195a\n--- /dev/null\n+++ b/apps/fabro-web/app/routes/settings-mcps-edit.test.tsx\n@@ -0,0 +1,82 @@\n+import { afterEach, beforeEach, describe, expect, mock, test } from \"bun:test\";\n+import type { McpServer } from \"@qltysh/fabro-api-client\";\n+import TestRenderer, { act } from \"react-test-renderer\";\n+import { MemoryRouter, Route, Routes } from \"react-router\";\n+\n+import { setupReactTestEnv } from \"../lib/test-utils\";\n+\n+let mcpServer: McpServer | null | undefined;\n+let teardownReactTestEnv: (() => void) | undefined;\n+\n+mock.module(\"../lib/queries\", () => ({\n+ useMcpServer: () => ({ data: mcpServer }),\n+}));\n+\n+const { default: SettingsMcpsEdit } = await import(\"./settings-mcps-edit\");\n+\n+const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];\n+\n+function renderSettingsMcpsEdit() {\n+ let renderer: TestRenderer.ReactTestRenderer | undefined;\n+ act(() => {\n+ renderer = TestRenderer.create(\n+ \n+ \n+ } />\n+ \n+ ,\n+ );\n+ });\n+ mountedRenderers.push(renderer!);\n+ return renderer!;\n+}\n+\n+function textContent(node: ReturnType): string {\n+ if (node == null || typeof node === \"boolean\") return \"\";\n+ if (typeof node === \"string\" || typeof node === \"number\") return String(node);\n+ if (Array.isArray(node)) return node.map(textContent).join(\"\");\n+ return node.children?.map(textContent).join(\"\") ?? \"\";\n+}\n+\n+describe(\"SettingsMcpsEdit route\", () => {\n+ beforeEach(() => {\n+ teardownReactTestEnv = setupReactTestEnv();\n+ });\n+\n+ afterEach(() => {\n+ act(() => {\n+ for (const renderer of mountedRenderers.splice(0)) {\n+ renderer.unmount();\n+ }\n+ });\n+ mcpServer = undefined;\n+ teardownReactTestEnv?.();\n+ teardownReactTestEnv = undefined;\n+ });\n+\n+ test(\"renders the write-only value banner and blank existing env values\", () => {\n+ mcpServer = {\n+ id: \"github\",\n+ revision: \"rev-1\",\n+ display_name: \"GitHub MCP\",\n+ description: \"GitHub tools\",\n+ startup_timeout_secs: 10,\n+ tool_timeout_secs: 60,\n+ transport: {\n+ type: \"stdio\",\n+ command: [\"npx\", \"server\"],\n+ env_keys: [\"GITHUB_TOKEN\"],\n+ },\n+ };\n+\n+ const renderer = renderSettingsMcpsEdit();\n+ const text = textContent(renderer.toJSON());\n+\n+ expect(text).toContain(\"Existing environment variable and header values are write-only\");\n+\n+ const keyInputs = renderer.root.findAllByProps({ \"aria-label\": \"Key\" });\n+ const valueInputs = renderer.root.findAllByProps({ \"aria-label\": \"Value\" });\n+ expect(keyInputs.map((input) => input.props.value)).toContain(\"GITHUB_TOKEN\");\n+ expect(valueInputs.map((input) => input.props.value)).toContain(\"\");\n+ });\n+});\ndiff --git a/apps/fabro-web/app/routes/settings-mcps-edit.tsx b/apps/fabro-web/app/routes/settings-mcps-edit.tsx\nnew file mode 100644\nindex 000000000..a518e9807\n--- /dev/null\n+++ b/apps/fabro-web/app/routes/settings-mcps-edit.tsx\n@@ -0,0 +1,157 @@\n+import { useState } from \"react\";\n+import { Link, useNavigate, useParams } from \"react-router\";\n+import { useSWRConfig } from \"swr\";\n+import { ChevronRightIcon } from \"@heroicons/react/20/solid\";\n+import type { McpServer } from \"@qltysh/fabro-api-client\";\n+\n+import {\n+ McpServerFormFields,\n+ isMcpServerFormValid,\n+ mcpServerToFormValues,\n+ replaceRequestFromForm,\n+ type McpServerFormValues,\n+} from \"../components/mcp-server-form\";\n+import { Panel, PanelSkeleton } from \"../components/settings-panel\";\n+import {\n+ ErrorMessage,\n+ PRIMARY_BUTTON_CLASS,\n+ SECONDARY_BUTTON_CLASS,\n+} from \"../components/ui\";\n+import { useToast } from \"../components/toast\";\n+import { ApiError, apiData, mcpServersApi } from \"../lib/api-client\";\n+import { queryKeys } from \"../lib/query-keys\";\n+import { useMcpServer } from \"../lib/queries\";\n+\n+export function meta() {\n+ return [{ title: \"Edit MCP server — Fabro\" }];\n+}\n+\n+export default function SettingsMcpsEdit() {\n+ const { id } = useParams<{ id: string }>();\n+ const query = useMcpServer(id);\n+\n+ return (\n+
\n+ \n+ {query.data ? (\n+ \n+ ) : query.error || query.data === null ? (\n+ \n+
\n+ Couldn't load this MCP server. It may have been deleted.\n+
\n+
\n+ ) : (\n+ \n+ )}\n+
\n+ );\n+}\n+\n+function PageHeader({ id }: { id: string }) {\n+ return (\n+ \n+ );\n+}\n+\n+function EditMcpServerForm({ server }: { server: McpServer }) {\n+ const navigate = useNavigate();\n+ const { mutate } = useSWRConfig();\n+ const toast = useToast();\n+ const [values, setValues] = useState(() =>\n+ mcpServerToFormValues(server),\n+ );\n+ const [submitting, setSubmitting] = useState(false);\n+ const [error, setError] = useState(null);\n+\n+ const canSubmit = isMcpServerFormValid(values, { isEdit: true }) && !submitting;\n+\n+ async function onSubmit(event: React.FormEvent) {\n+ event.preventDefault();\n+ if (!canSubmit) return;\n+ setSubmitting(true);\n+ setError(null);\n+ try {\n+ await apiData(() =>\n+ mcpServersApi.replaceMcpServer(\n+ server.id,\n+ server.revision,\n+ replaceRequestFromForm(values),\n+ ),\n+ );\n+ await mutate(queryKeys.mcpServers.list());\n+ await mutate(queryKeys.mcpServers.detail(server.id));\n+ toast.push({ message: `MCP server “${server.id}” updated.` });\n+ navigate(\"/settings/mcps\");\n+ } catch (cause) {\n+ setError(staleAwareMessage(cause));\n+ setSubmitting(false);\n+ }\n+ }\n+\n+ return (\n+
\n+ {hasWriteOnlyValues(server) ? : null}\n+\n+ \n+\n+ {error ? : null}\n+\n+
\n+ navigate(\"/settings/mcps\")}\n+ disabled={submitting}\n+ className={SECONDARY_BUTTON_CLASS}\n+ >\n+ Cancel\n+ \n+ \n+
\n+ \n+ );\n+}\n+\n+function WriteOnlyValuesBanner() {\n+ return (\n+
\n+ Existing environment variable and header values are write-only and are not shown. Saving\n+ replaces the full set — re-enter every value you want to keep.\n+
\n+ );\n+}\n+\n+function hasWriteOnlyValues(server: McpServer): boolean {\n+ switch (server.transport.type) {\n+ case \"stdio\":\n+ return server.transport.env_keys.length > 0;\n+ case \"http\":\n+ return server.transport.header_keys.length > 0;\n+ case \"sandbox\":\n+ return server.transport.env_keys.length > 0;\n+ }\n+}\n+\n+function staleAwareMessage(cause: unknown): string {\n+ if (cause instanceof ApiError && cause.status === 409) {\n+ return \"This MCP server changed since you opened it. Reload the page to get the latest version, then reapply your edits.\";\n+ }\n+ if (cause instanceof ApiError && cause.message) {\n+ return cause.message;\n+ }\n+ return \"Couldn't update the MCP server. Please try again.\";\n+}\ndiff --git a/apps/fabro-web/app/routes/settings-mcps-new.test.tsx b/apps/fabro-web/app/routes/settings-mcps-new.test.tsx\nnew file mode 100644\nindex 000000000..553347939\n--- /dev/null\n+++ b/apps/fabro-web/app/routes/settings-mcps-new.test.tsx\n@@ -0,0 +1,56 @@\n+import { afterEach, beforeEach, describe, expect, test } from \"bun:test\";\n+import TestRenderer, { act } from \"react-test-renderer\";\n+import { MemoryRouter } from \"react-router\";\n+\n+import { setupReactTestEnv } from \"../lib/test-utils\";\n+\n+const { default: SettingsMcpsNew } = await import(\"./settings-mcps-new\");\n+\n+let teardownReactTestEnv: (() => void) | undefined;\n+const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];\n+\n+function renderSettingsMcpsNew(initialEntry = \"/settings/mcps/new\") {\n+ let renderer: TestRenderer.ReactTestRenderer | undefined;\n+ act(() => {\n+ renderer = TestRenderer.create(\n+ \n+ \n+ ,\n+ );\n+ });\n+ mountedRenderers.push(renderer!);\n+ return renderer!;\n+}\n+\n+function textContent(node: ReturnType): string {\n+ if (node == null || typeof node === \"boolean\") return \"\";\n+ if (typeof node === \"string\" || typeof node === \"number\") return String(node);\n+ if (Array.isArray(node)) return node.map(textContent).join(\"\");\n+ return node.children?.map(textContent).join(\"\") ?? \"\";\n+}\n+\n+describe(\"SettingsMcpsNew route\", () => {\n+ beforeEach(() => {\n+ teardownReactTestEnv = setupReactTestEnv();\n+ });\n+\n+ afterEach(() => {\n+ act(() => {\n+ for (const renderer of mountedRenderers.splice(0)) {\n+ renderer.unmount();\n+ }\n+ });\n+ teardownReactTestEnv?.();\n+ teardownReactTestEnv = undefined;\n+ });\n+\n+ test(\"renders the create form for the requested transport\", () => {\n+ const renderer = renderSettingsMcpsNew(\"/settings/mcps/new?type=http\");\n+ const text = textContent(renderer.toJSON());\n+\n+ expect(text).toContain(\"New MCP server\");\n+ expect(renderer.root.findByProps({ \"aria-label\": \"Transport\" }).props.value)\n+ .toBe(\"http\");\n+ expect(renderer.root.findByProps({ \"aria-label\": \"URL\" })).toBeDefined();\n+ });\n+});\ndiff --git a/apps/fabro-web/app/routes/settings-mcps-new.tsx b/apps/fabro-web/app/routes/settings-mcps-new.tsx\nnew file mode 100644\nindex 000000000..0a0754597\n--- /dev/null\n+++ b/apps/fabro-web/app/routes/settings-mcps-new.tsx\n@@ -0,0 +1,103 @@\n+import { useState } from \"react\";\n+import { Link, useNavigate, useSearchParams } from \"react-router\";\n+import { useSWRConfig } from \"swr\";\n+import { ChevronRightIcon } from \"@heroicons/react/20/solid\";\n+\n+import {\n+ McpServerFormFields,\n+ createRequestFromForm,\n+ defaultMcpServerFormValues,\n+ isMcpServerFormValid,\n+ parseMcpTransportKind,\n+ type McpServerFormValues,\n+} from \"../components/mcp-server-form\";\n+import {\n+ ErrorMessage,\n+ PRIMARY_BUTTON_CLASS,\n+ SECONDARY_BUTTON_CLASS,\n+} from \"../components/ui\";\n+import { useToast } from \"../components/toast\";\n+import { ApiError, apiData, mcpServersApi } from \"../lib/api-client\";\n+import { queryKeys } from \"../lib/query-keys\";\n+\n+export function meta() {\n+ return [{ title: \"New MCP server — Fabro\" }];\n+}\n+\n+export default function SettingsMcpsNew() {\n+ return (\n+
\n+ \n+ \n+
\n+ );\n+}\n+\n+function PageHeader() {\n+ return (\n+ \n+ );\n+}\n+\n+function CreateMcpServerForm() {\n+ const navigate = useNavigate();\n+ const { mutate } = useSWRConfig();\n+ const toast = useToast();\n+ const [searchParams] = useSearchParams();\n+ const [values, setValues] = useState(() =>\n+ defaultMcpServerFormValues(parseMcpTransportKind(searchParams.get(\"type\"))),\n+ );\n+ const [submitting, setSubmitting] = useState(false);\n+ const [error, setError] = useState(null);\n+\n+ const canSubmit = isMcpServerFormValid(values, { isEdit: false }) && !submitting;\n+\n+ async function onSubmit(event: React.FormEvent) {\n+ event.preventDefault();\n+ if (!canSubmit) return;\n+ setSubmitting(true);\n+ setError(null);\n+ const id = values.id.trim();\n+ try {\n+ await apiData(() => mcpServersApi.createMcpServer(createRequestFromForm(values)));\n+ await mutate(queryKeys.mcpServers.list());\n+ toast.push({ message: `MCP server “${id}” created.` });\n+ navigate(\"/settings/mcps\");\n+ } catch (cause) {\n+ setError(\n+ cause instanceof ApiError && cause.message\n+ ? cause.message\n+ : \"Couldn't create the MCP server. Please try again.\",\n+ );\n+ setSubmitting(false);\n+ }\n+ }\n+\n+ return (\n+
\n+ \n+\n+ {error ? : null}\n+\n+
\n+ navigate(\"/settings/mcps\")}\n+ disabled={submitting}\n+ className={SECONDARY_BUTTON_CLASS}\n+ >\n+ Cancel\n+ \n+ \n+
\n+ \n+ );\n+}\ndiff --git a/apps/fabro-web/app/routes/settings-mcps.test.tsx b/apps/fabro-web/app/routes/settings-mcps.test.tsx\nnew file mode 100644\nindex 000000000..c7b030154\n--- /dev/null\n+++ b/apps/fabro-web/app/routes/settings-mcps.test.tsx\n@@ -0,0 +1,106 @@\n+import { afterEach, beforeEach, describe, expect, mock, test } from \"bun:test\";\n+import type { McpServerListResponse } from \"@qltysh/fabro-api-client\";\n+import TestRenderer, { act } from \"react-test-renderer\";\n+import { MemoryRouter } from \"react-router\";\n+\n+import { setupReactTestEnv } from \"../lib/test-utils\";\n+\n+let mcpServers: McpServerListResponse | undefined;\n+let teardownReactTestEnv: (() => void) | undefined;\n+\n+mock.module(\"../lib/queries\", () => ({\n+ useMcpServers: () => ({ data: mcpServers }),\n+}));\n+\n+const { default: SettingsMcps } = await import(\"./settings-mcps\");\n+\n+const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];\n+\n+function renderSettingsMcps() {\n+ let renderer: TestRenderer.ReactTestRenderer | undefined;\n+ act(() => {\n+ renderer = TestRenderer.create(\n+ \n+ \n+ ,\n+ );\n+ });\n+ mountedRenderers.push(renderer!);\n+ return renderer!;\n+}\n+\n+function textContent(node: ReturnType): string {\n+ if (node == null || typeof node === \"boolean\") return \"\";\n+ if (typeof node === \"string\" || typeof node === \"number\") return String(node);\n+ if (Array.isArray(node)) return node.map(textContent).join(\"\");\n+ return node.children?.map(textContent).join(\"\") ?? \"\";\n+}\n+\n+describe(\"SettingsMcps route\", () => {\n+ beforeEach(() => {\n+ teardownReactTestEnv = setupReactTestEnv();\n+ });\n+\n+ afterEach(() => {\n+ act(() => {\n+ for (const renderer of mountedRenderers.splice(0)) {\n+ renderer.unmount();\n+ }\n+ });\n+ mcpServers = undefined;\n+ teardownReactTestEnv?.();\n+ teardownReactTestEnv = undefined;\n+ });\n+\n+ test(\"renders MCP server rows\", () => {\n+ mcpServers = {\n+ data: [\n+ {\n+ id: \"github\",\n+ revision: \"rev-1\",\n+ display_name: \"GitHub MCP\",\n+ description: \"GitHub tools\",\n+ startup_timeout_secs: 10,\n+ tool_timeout_secs: 60,\n+ transport: {\n+ type: \"http\",\n+ url: \"https://example.com/mcp\",\n+ header_keys: [\"Authorization\"],\n+ },\n+ },\n+ {\n+ id: \"filesystem\",\n+ revision: \"rev-2\",\n+ display_name: \"Filesystem MCP\",\n+ description: null,\n+ startup_timeout_secs: 10,\n+ tool_timeout_secs: 60,\n+ transport: {\n+ type: \"stdio\",\n+ command: [\"npx\", \"server\"],\n+ env_keys: [],\n+ },\n+ },\n+ ],\n+ meta: { total: 2 },\n+ };\n+\n+ const renderer = renderSettingsMcps();\n+ const text = textContent(renderer.toJSON());\n+\n+ expect(text).toContain(\"GitHub MCP\");\n+ expect(text).toContain(\"github\");\n+ expect(text).toContain(\"http\");\n+ expect(text).toContain(\"Filesystem MCP\");\n+ expect(text).toContain(\"stdio\");\n+ });\n+\n+ test(\"renders an empty state\", () => {\n+ mcpServers = { data: [], meta: { total: 0 } };\n+\n+ const renderer = renderSettingsMcps();\n+ const text = textContent(renderer.toJSON());\n+\n+ expect(text).toContain(\"No MCP servers defined yet.\");\n+ });\n+});\ndiff --git a/apps/fabro-web/app/routes/settings-mcps.tsx b/apps/fabro-web/app/routes/settings-mcps.tsx\nnew file mode 100644\nindex 000000000..92ec252ff\n--- /dev/null\n+++ b/apps/fabro-web/app/routes/settings-mcps.tsx\n@@ -0,0 +1,259 @@\n+import { useState } from \"react\";\n+import { Link } from \"react-router\";\n+import { useSWRConfig } from \"swr\";\n+import { Menu, MenuButton, MenuItem, MenuItems } from \"@headlessui/react\";\n+import { ChevronDownIcon, PlusIcon } from \"@heroicons/react/16/solid\";\n+import { EllipsisVerticalIcon } from \"@heroicons/react/20/solid\";\n+import type { McpServer } from \"@qltysh/fabro-api-client\";\n+\n+import { MCP_TRANSPORT_KINDS, type McpTransportKind } from \"../components/mcp-server-form\";\n+import {\n+ Badge,\n+ Muted,\n+ Panel,\n+ PanelSkeleton,\n+ SettingsPageIntro,\n+} from \"../components/settings-panel\";\n+import { ConfirmDialog } from \"../components/ui\";\n+import { useToast } from \"../components/toast\";\n+import { ApiError, apiData, mcpServersApi } from \"../lib/api-client\";\n+import { queryKeys } from \"../lib/query-keys\";\n+import { useMcpServers } from \"../lib/queries\";\n+\n+const MENU_ITEM_CLASS =\n+ \"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\";\n+\n+const MENU_ITEM_DANGER_CLASS =\n+ \"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\";\n+\n+const NEW_BUTTON_CLASS =\n+ \"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\";\n+\n+const DESCRIPTION =\n+ \"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.\";\n+\n+export function meta() {\n+ return [{ title: \"MCP servers — Fabro\" }];\n+}\n+\n+export default function SettingsMcps() {\n+ const query = useMcpServers();\n+\n+ return (\n+
\n+ } />\n+ {query.data ? (\n+ \n+ ) : query.error ? (\n+ \n+
\n+ Couldn't load MCP servers. Please try again.\n+
\n+
\n+ ) : (\n+ \n+ )}\n+
\n+ );\n+}\n+\n+function NewMcpServerMenu() {\n+ return (\n+ \n+ \n+ \n+ New MCP server\n+ \n+ \n+ \n+ {MCP_TRANSPORT_KINDS.map((kind) => (\n+ \n+ \n+ {transportLabel(kind)}\n+ \n+ \n+ ))}\n+ \n+ \n+ );\n+}\n+\n+function McpServersPanel({ servers }: { servers: McpServer[] }) {\n+ const { mutate } = useSWRConfig();\n+ const toast = useToast();\n+ const [pendingDelete, setPendingDelete] = useState(null);\n+ const [deleting, setDeleting] = useState(false);\n+\n+ async function confirmDelete() {\n+ if (!pendingDelete) return;\n+ const target = pendingDelete;\n+ setDeleting(true);\n+ try {\n+ await apiData(() => mcpServersApi.deleteMcpServer(target.id, target.revision));\n+ await mutate(queryKeys.mcpServers.list());\n+ toast.push({ message: `MCP server “${target.id}” deleted.` });\n+ setPendingDelete(null);\n+ } catch (cause) {\n+ if (cause instanceof ApiError && cause.status === 409) {\n+ await mutate(queryKeys.mcpServers.list());\n+ toast.push({\n+ tone: \"error\",\n+ message: \"This MCP server changed before it could be deleted. Refresh and try again.\",\n+ });\n+ } else {\n+ toast.push({\n+ tone: \"error\",\n+ message:\n+ cause instanceof ApiError && cause.message\n+ ? cause.message\n+ : \"Couldn't delete the MCP server. Please try again.\",\n+ });\n+ }\n+ } finally {\n+ setDeleting(false);\n+ }\n+ }\n+\n+ return (\n+ <>\n+ \n+ {servers.length === 0 ? (\n+
\n+ No MCP servers defined yet.\n+
\n+ ) : (\n+ servers.map((server) => (\n+ setPendingDelete(server)}\n+ />\n+ ))\n+ )}\n+
\n+ \n+ Delete {pendingDelete?.id}? Workflows\n+ that enable this server will fail until it is recreated.\n+ \n+ }\n+ confirmLabel=\"Delete\"\n+ pendingLabel=\"Deleting…\"\n+ pending={deleting}\n+ onConfirm={confirmDelete}\n+ onCancel={() => {\n+ if (!deleting) setPendingDelete(null);\n+ }}\n+ />\n+ \n+ );\n+}\n+\n+function McpServerRow({\n+ server,\n+ disabled,\n+ onDelete,\n+}: {\n+ server: McpServer;\n+ disabled: boolean;\n+ onDelete: () => void;\n+}) {\n+ const summary = transportSummary(server);\n+ return (\n+
\n+
\n+
\n+ \n+ {server.display_name}\n+ \n+ {server.transport.type}\n+
\n+
\n+ {server.id}\n+ {server.description ? {server.description} : null}\n+
\n+
\n+
\n+ {summary ?? No transport details}\n+
\n+ \n+
\n+ );\n+}\n+\n+function transportSummary(server: McpServer): string | null {\n+ switch (server.transport.type) {\n+ case \"stdio\":\n+ return server.transport.command.join(\" \") || null;\n+ case \"http\":\n+ return server.transport.url;\n+ case \"sandbox\": {\n+ const command = server.transport.command.join(\" \");\n+ return command ? `${command} · port ${server.transport.port}` : `port ${server.transport.port}`;\n+ }\n+ }\n+}\n+\n+function transportLabel(kind: McpTransportKind): string {\n+ return kind.charAt(0).toUpperCase() + kind.slice(1);\n+}\n+\n+function RowMenu({\n+ server,\n+ disabled,\n+ onDelete,\n+}: {\n+ server: McpServer;\n+ disabled: boolean;\n+ onDelete: () => void;\n+}) {\n+ return (\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ Edit\n+ \n+ \n+
\n+ \n+ \n+ Delete\n+ \n+ \n+ \n+
\n+ );\n+}\ndiff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx\nindex 7a803567d..08927d5a5 100644\n--- a/apps/fabro-web/app/routes/settings.tsx\n+++ b/apps/fabro-web/app/routes/settings.tsx\n@@ -73,6 +73,13 @@ export const navSections: NavSection[] = [\n description: \"Server-managed runtime definitions for runs.\",\n match: (p) => p.startsWith(\"/settings/environments\"),\n },\n+ {\n+ name: \"MCP servers\",\n+ href: \"/settings/mcps\",\n+ icon: PuzzlePieceIcon,\n+ description: \"Server-managed MCP servers you can enable by name in workflows.\",\n+ match: (p) => p.startsWith(\"/settings/mcps\"),\n+ },\n {\n name: \"Variables\",\n href: \"/settings/variables\",\n", + "summary": { + "files_changed": 20, + "additions": 1919, + "deletions": 94 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-06-30T20:38:45.253701583Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { + "failure_signature": "", + "internal.retry_count.implement": 0, + "thread.preflight_compile.current_node": "preflight_lint", + "thread.preflight_lint.current_node": "implement", + "last_stage": "simplify_opus", + "graph.rankdir": "LR", + "response.implement": "Implemented the MCP servers settings UI under `apps/fabro-web/app/` only.\n\nSummary:\n- Added `/settings/mcps`, `/settings/mcps/new`, and `/settings/mcps/:id/edit`.\n- Added MCP list/create/edit pages with CRUD API usage, revision/If-Match handling, delete confirmation, stale 409 messaging, and settings nav link.\n- Added shared `KeyValueEditor` extraction and reused it in environments + MCP forms.\n- Added MCP form model helpers, validation, read-model-to-form mapping, write-only value guard, and credential warning/nudge flow.\n- Added MCP query keys, SWR hooks, and `MCPServersApi` wiring.\n- Added tests for credential heuristics, key/value editor, MCP form helpers, list/new/edit route smoke behavior, query keys, and router registration.\n- Did not touch Rust, OpenAPI, generated clients, or docs.\n\nValidation:\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun test` ✅ — 650 pass\n- `cd apps/fabro-web && bun run build` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nThe implementation follows the requested write-only edit behavior: existing env/header keys are shown with empty values, the edit banner explains full replacement, and saving is blocked on edit until every non-empty key has a value.", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ", "internal.node_visit_count": 1, "outcome": "succeeded", "internal.retry_count.preflight_lint": 0, "graph.goal": "# Implementation plan: MCP servers settings UI (`/settings/mcps`)\n\n> **For the implement-plan workflow.** This is a self-contained spec for adding a\n> web management UI for server-managed MCP servers. Implement **every** step.\n> Use red/green TDD: write the failing `bun test` first, then the code.\n>\n> **Delivery note (for the human, not the agent):** the workflow's implement step\n> reads \"the plan file referenced in the goal,\" so this file must be visible to\n> the cloned workspace — commit it at e.g. `docs/plans/mcp-settings-ui.md` on the\n> branch the instance clones, or paste its contents into the run goal.\n\n---\n\n## 1. What this task is (and what is already done)\n\nThe **entire backend for server-managed MCP servers is already built, merged, and\nlive on `main`.** That includes the on-disk store, the OpenAPI spec, the HTTP CRUD\nhandlers with credential-omitting reads, and the workflow-side reference resolver\nthat lets a workflow enable a stored server by name. **None of that is in scope\nhere and none of it should be changed.**\n\nThis task is **frontend only**: a settings catalog page at `/settings/mcps` that\nlets a user create, list, edit, and delete MCP server definitions through the\nalready-shipped HTTP API, following the exact patterns used by the existing\n`/settings/environments` and `/settings/secrets` pages.\n\nEverything you build lives under `apps/fabro-web/app/`. You consume the\n**already-generated** TypeScript client `@qltysh/fabro-api-client` (the\n`MCPServersApi` class and its models already exist — do not regenerate it).\n\n---\n\n## 2. Scope\n\n**In scope (all under `apps/fabro-web/app/`):**\n\n- A list page, a \"new\" page, and an \"edit\" page for MCP servers.\n- A shared MCP form component with stdio / http / sandbox transport variants.\n- Key/value editors for environment variables and HTTP headers.\n- A credential-looking-value warning with a \"use a secret instead\" nudge\n (warn-but-allow — never block save).\n- Query hooks + query keys + API client wiring.\n- Route registration and (last) the settings nav link.\n- `bun test` unit tests for every pure helper and a render smoke test per page.\n\n**Out of scope — do NOT touch any of these (they are done or are future work):**\n\n- ❌ Any Rust crate: `fabro-config`, `fabro-types`, `fabro-mcp-store`,\n `fabro-server`, `fabro-workflow`, `fabro-api`, etc. The resolver, store, and\n handlers are complete.\n- ❌ The OpenAPI spec (`docs/public/api-reference/fabro-api.yaml`) and the\n generated clients. `MCPServersApi` is already generated and importable. Do not\n run `bun run generate`, do not edit `lib/packages/fabro-api-client`.\n- ❌ Adding/altering API endpoints or request/response shapes. In particular, do\n **not** invent a PATCH/partial-update endpoint to work around the read model\n omitting env/header values (see §5) — that is deliberately future work.\n- ❌ The reference-resolution behavior, the run-only `fabro exec` rejection, the\n reference TOML syntax, or anything about how runs consume the catalog.\n- ❌ The SQLite migration of the store.\n- ❌ Documentation (`mcp.mdx`) and end-to-end Rust tests — separate task.\n\nIf you find yourself editing a `.rs` file or `fabro-api.yaml`, stop — you have\nleft scope.\n\n---\n\n## 3. Locked decisions — do NOT relitigate\n\nThese are settled. Build to them; don't redesign them.\n\n1. **Credentials: warn, don't block.** When an env-var or header looks\n credential-bearing, show a warning and offer a one-click path to store it as a\n secret and reference it with the secrets name rather than the value. **Still allow the user to\n save.** Do not hard-reject. Ordinary literals (`NODE_ENV=production`, ports,\n feature flags, non-sensitive headers) save without friction.\n2. **Reads never expose secret values.** The API returns only env/header **names**\n (`env_keys` / `header_keys`), never values. The UI must never attempt to\n display a stored value and must not assume it can read one back.\n3. **Settings-nested, not top-level.** This lives under `/settings/mcps`, mirroring\n `/settings/environments` and `/settings/secrets`. It is not a top-level nav\n destination.\n4. **Optimistic concurrency via `revision` + `If-Match`.** Same as environments:\n capture `revision` from the read, pass it to replace/delete, surface a 409 as a\n \"changed since you opened it — reload\" message. No auto-retry.\n5. **No form library, no `useEffect`.** Plain `useState` controlled inputs, pure\n validation functions, SWR for server state, `key={revision}` to reset the edit\n form. This is the house style and the repo enforces an effects policy\n (`docs/internal/react-effects-policy.md`).\n6. **Transport type is chosen at create time and locked on edit** — mirroring how\n environments lock `provider` at creation. (You may revisit later; for v1, lock\n it. The id is likewise locked on edit.)\n\n---\n\n## 4. The API contract you consume\n\nAlready generated in `@qltysh/fabro-api-client`. Methods on `MCPServersApi`:\n\n| Method | Signature | Returns |\n|---|---|---|\n| list | `listMcpServers()` | `McpServerListResponse` (`{ data: McpServer[], meta: { total } }`) |\n| get | `retrieveMcpServer(id)` | `McpServer` |\n| create | `createMcpServer(CreateMcpServerRequest)` | `McpServer` |\n| replace | `replaceMcpServer(id, ifMatch, ReplaceMcpServerRequest)` | `McpServer` |\n| delete | `deleteMcpServer(id, ifMatch)` | `void` |\n\nThe generated client automatically sets the `If-Match` header from the `ifMatch`\nargument.\n\n**Read model — `McpServer` (values omitted):**\n\n```ts\ninterface McpServer {\n id: string; // stable id; also the runtime MCP server name\n revision: string; // pass as If-Match on replace/delete\n display_name: string;\n description: string | null;\n transport: McpTransportView; // union by `type`; VALUES OMITTED\n startup_timeout_secs: number;\n tool_timeout_secs: number;\n}\n\n// McpTransportView = http | sandbox | stdio, each WITHOUT values:\n// stdio: { type: 'stdio', command: string[], env_keys: string[] }\n// http: { type: 'http', protocol?: McpHttpProtocol, url: string, header_keys: string[] }\n// sandbox: { type: 'sandbox', protocol?: McpHttpProtocol, command: string[], port: number, env_keys: string[] }\n```\n\n**Write model — `CreateMcpServerRequest` / `ReplaceMcpServerRequest` (values required):**\n\n```ts\ninterface CreateMcpServerRequest {\n id: string; // create only; on replace the path id is authoritative\n display_name: string;\n description?: string | null;\n transport: McpTransport; // union by `type`; VALUES INCLUDED\n startup_timeout_secs: number;\n tool_timeout_secs: number;\n}\n// ReplaceMcpServerRequest is the same minus `id`.\n\n// McpTransport = http | sandbox | stdio, each WITH values:\n// stdio: { type: 'stdio', command: string[], env: Record }\n// http: { type: 'http', protocol?: McpHttpProtocol, url: string, headers: Record }\n// sandbox: { type: 'sandbox', protocol?: McpHttpProtocol, command: string[], port: number, env: Record }\n\n// McpHttpProtocol = 'streamable_http' | 'sse' (default streamable_http; only on http/sandbox)\n```\n\n`replace` is a **full PUT** (whole transport replaced). There is no merge/preserve.\n\n---\n\n## 5. The one genuinely tricky UX decision: editing when values are write-only\n\nBecause reads return only `env_keys`/`header_keys` and replace is a full PUT, **the\nedit form cannot read back env/header values, and saving overwrites the entire\ntransport.** Handle it explicitly — do not try to fix it server-side:\n\n- On the **edit** form, pre-populate env/header rows with the existing **keys** and\n **empty values**.\n- Show a clear banner on the edit form whenever the transport has any\n `env_keys`/`header_keys`:\n > \"Existing environment variable and header values are write-only and are not\n > shown. Saving replaces the full set — re-enter every value you want to keep.\"\n- On the edit form only, **block save** (with an inline row error) if any row has a\n non-empty key but an empty value, so the user can't silently blank out values.\n (On the create form, empty values are simply allowed/optional per the API.)\n\nThis keeps the destructive-overwrite behavior visible and intentional.\n\n---\n\n## 6. Reference implementation to copy from\n\nMirror these existing files closely (same structure, naming, classes, helpers):\n\n- Routes: `apps/fabro-web/app/router.tsx` (environments entries, ~lines 147–166)\n- Nav: `apps/fabro-web/app/routes/settings.tsx` (`navSections`, `NavItem`/`NavSection`)\n- List: `apps/fabro-web/app/routes/settings-environments.tsx`\n- New: `apps/fabro-web/app/routes/settings-environments-new.tsx`\n- Edit: `apps/fabro-web/app/routes/settings-environments-edit.tsx`\n- Form + key/value editor: `apps/fabro-web/app/components/environment-form.tsx`\n (`EnvironmentFormFields`, `KeyValueEditor`, `entriesFromMap`/`mapFromEntries`)\n- Panels: `apps/fabro-web/app/components/settings-panel.tsx`\n (`Panel`, `Row`, `SettingsPageIntro`, `PanelSkeleton`, `Badge`, `Muted`)\n- Query keys: `apps/fabro-web/app/lib/query-keys.ts` (`environments.{list,detail}`)\n- Query hooks: `apps/fabro-web/app/lib/queries.ts` (`useEnvironments`, `useEnvironment`)\n- API client: `apps/fabro-web/app/lib/api-client.ts` (`environmentsApi`, `apiData`, `apiNullableData`, `ApiError`)\n- Secrets create flow (for the nudge target): `apps/fabro-web/app/routes/settings-secrets-new.tsx` (reads `?name=`)\n- Test convention: `apps/fabro-web/app/routes/settings-integrations.test.tsx`\n (`bun:test`, `react-test-renderer`, `setupReactTestEnv`, `mock.module(\"../lib/queries\", ...)`)\n\n---\n\n## 7. Implementation steps (TDD; do them in this order)\n\nEach step: write the failing test(s) first where a test is called for, then\nimplement until green. Run `cd apps/fabro-web && bun test` and `bun run typecheck`\nfrequently.\n\n### Step 1 — Extract the shared key/value editor\n\n`KeyValueEditor`, the `KeyValueEntry` type, and `entriesFromMap` / `mapFromEntries`\ncurrently live inside `components/environment-form.tsx`. Move them verbatim (no\nbehavior change) into a new `apps/fabro-web/app/components/key-value-editor.tsx`\nand re-import them in `environment-form.tsx`. Both the MCP form and the environment\nform then share one implementation.\n\n- Test: `components/key-value-editor.test.tsx` — `mapFromEntries` drops blank keys\n and trims, `entriesFromMap` round-trips; a render test adds/removes a row.\n- Verify environments still typecheck and any existing environment tests pass.\n\n### Step 2 — Query keys\n\nIn `lib/query-keys.ts`, add alongside `environments`:\n\n```ts\nmcpServers: {\n list: () => [\"mcp-servers\", \"list\"] as const,\n detail: (id: string) => [\"mcp-servers\", \"detail\", id] as const,\n},\n```\n\n### Step 3 — API client wiring\n\nIn `lib/api-client.ts`, import `MCPServersApi` from `@qltysh/fabro-api-client` and\ninstantiate it exactly like `environmentsApi`:\n\n```ts\nexport const mcpServersApi = new MCPServersApi(\n generatedApiConfiguration,\n \"\",\n generatedAxios,\n);\n```\n\n### Step 4 — Query hooks\n\nIn `lib/queries.ts`, mirror the environments hooks:\n\n```ts\nexport function useMcpServers() {\n return useSWR(\n queryKeys.mcpServers.list(),\n () => apiData(() => mcpServersApi.listMcpServers()),\n );\n}\n\nexport function useMcpServer(id: string | undefined) {\n return useSWR(\n id ? queryKeys.mcpServers.detail(id) : null,\n id ? () => apiNullableData(() => mcpServersApi.retrieveMcpServer(id)) : null,\n );\n}\n```\n\n### Step 5 — Credential detection helper (pure, TDD)\n\nNew `apps/fabro-web/app/lib/credential-heuristics.ts`. Pure functions, fully unit\ntested first.\n\n```ts\n// True when a key/value pair looks credential-bearing and should be nudged\n// toward a secret. Never flags an already-templated value.\nexport function looksLikeCredential(key: string, value: string): boolean;\n\n// Suggest a secret name derived from the key (UPPER_SNAKE_CASE, alnum + _).\nexport function secretNameForKey(key: string): string;\n\n// The interpolation reference to store in place of a literal secret value.\nexport function secretReference(name: string): string; // `\\{\\{ secrets.NAME \\}\\}`\n```\n\nRules for `looksLikeCredential`:\n- Return **false** if `value` is empty, or already a template\n (`/\\{\\{\\s*(secrets|env|vars)\\./` matches) — references are fine as-is.\n- Return **true** if the key (case-insensitive) matches any of:\n `authorization`, `password`, `passwd`, `secret`, `token`, `api[-_]?key`,\n or ends in `_key` / `_token` / `_secret`.\n- Otherwise return **true** if the value looks high-entropy: length ≥ 20, contains\n a mix of at least two of {lowercase, uppercase, digit}, and has no spaces.\n- Otherwise **false**.\n\n`secretNameForKey`: uppercase, replace non-`[A-Za-z0-9]` with `_`, collapse\nrepeats, trim leading/trailing `_`; if empty, fall back to `SECRET`.\n\nTests must cover: `Authorization`/`Bearer abc...` → flagged; `API_KEY` → flagged;\n`NODE_ENV`/`production` → not flagged; a `\\{\\{ secrets.X \\}\\}` value → not flagged;\nempty value → not flagged; a long random-looking value under a benign key →\nflagged; `secretNameForKey(\"x-api-key\") === \"X_API_KEY\"`.\n\n### Step 6 — Form model: types, mappers, validation (pure, TDD)\n\nNew `apps/fabro-web/app/components/mcp-server-form.tsx` (form component) plus a\nsmall co-located or `lib/`-level module of pure helpers (tested first):\n\nForm value shape (flat, transport-discriminated):\n\n```ts\nexport type McpTransportKind = \"stdio\" | \"http\" | \"sandbox\";\n\nexport interface McpServerFormValues {\n id: string;\n displayName: string;\n description: string;\n startupTimeoutSecs: number;\n toolTimeoutSecs: number;\n transport: McpTransportKind;\n // stdio + sandbox\n command: string; // shell-style words; split on whitespace -> string[]\n // http + sandbox\n protocol: McpHttpProtocol; // default 'streamable_http'\n // http\n url: string;\n headers: KeyValueEntry[];\n // sandbox\n port: number;\n // stdio + sandbox env\n env: KeyValueEntry[];\n}\n```\n\nPure helpers + tests:\n\n- `defaultMcpServerFormValues(kind): McpServerFormValues` — sensible defaults\n (timeouts mirror `McpServerSettings::default()`: startup 10, tool 60).\n- `mcpServerToFormValues(server: McpServer): McpServerFormValues` — for edit.\n Maps `transport` **view** into the form. **Env/header values are not available**,\n so populate `env`/`headers` entries from `env_keys`/`header_keys` with **empty\n values** (see §5).\n- `createRequestFromForm(values): CreateMcpServerRequest` and\n `replaceRequestFromForm(values): ReplaceMcpServerRequest` — build the\n `McpTransport` (values included), splitting `command` on whitespace, converting\n entry arrays via `mapFromEntries`, omitting `protocol` when default.\n- `isMcpServerFormValid(values, { isEdit }): boolean` — id matches\n `/^[a-z0-9][a-z0-9-]{0,62}$/` (create only), `displayName` non-empty, and per\n transport: stdio needs `command`; http needs `url`; sandbox needs `command` and a\n valid `port` (1–65535). On edit, additionally require a value for every row with\n a non-empty key (the §5 guard).\n- `credentialWarnings(values): { field: 'env'|'headers'; index: number }[]` —\n derived during render from `looksLikeCredential`, used to render per-row warnings.\n\nRound-trip test: `mcpServerToFormValues` → `replaceRequestFromForm` preserves id,\ndisplay name, transport type, url/command/port; and (documented) drops values that\nweren't re-entered.\n\n### Step 7 — The form component\n\n`McpServerFormFields({ values, onChange, lockId, lockTransport })` in\n`components/mcp-server-form.tsx`, built from `Panel` / `Row` and a local `patch()`\nhelper (copy the environment-form idiom):\n\n- **General panel:** `id` (locked on edit), `display_name`, `description`,\n transport `` now maps over `MCP_TRANSPORT_KINDS` instead of hardcoded `