mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
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
## 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>
564 lines
16 KiB
TypeScript
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",
|
|
);
|
|
}
|