Skip to content

Commit fead02c

Browse files
authored
🤖 feat: add MCP headers table editor (#1287)
Adds a structured HTTP headers editor for remote MCP servers, replacing the raw JSON textarea in: - **Add server** (http/sse/auto) - **Edit server** (inline) Notable behavior: - Per-header **Text** vs **Secret** mode (secret references project secrets by key, with suggestions) - Validation for common pitfalls (case-insensitive duplicates, accidental `:` in names, newline rejection) - Keeps persisted schema unchanged: `headers?: Record<string, string | { secret: string }>` Tests: - `src/browser/utils/mcpHeaders.test.ts` --- <details> <summary>📋 Implementation Plan</summary> # Plan: Friendly HTTP headers editor for remote MCP servers ## Goals - Replace the **raw “Headers (JSON)” textarea** with a **table-style header editor** in both places it appears: 1) **Add server** form (HTTP/SSE/Auto transports) 2) **Edit existing server** inline editor (HTTP/SSE/Auto) - Make it easy to configure: - Normal header values (strings) - Secret-backed header values (`{ secret: "…" }`) without writing JSON - Keep config storage/back-end APIs **unchanged** (`headers?: Record<string, string | {secret: string}>`). ## What we have today (repo findings) - The MCP server UI lives in `src/browser/components/Settings/sections/ProjectSettingsSection.tsx`. - State uses `headersJson: string` and `parseHeadersJson()` to validate/convert it. - Both “Add server” and “Edit server” show a `textarea` for JSON. - Header types are already modeled: - `MCPHeaderValue = string | { secret: string }` (`src/common/types/mcp.ts`) - Zod schema mirrors this (`src/common/orpc/schemas/mcp.ts`). - **Radix UI doesn’t provide a Table component**; this repo uses Radix primitives + shadcn wrappers (`src/browser/components/ui/*`) and Tailwind. - The closest existing “key/value table editor” is **SecretsModal** (`src/browser/components/SecretsModal.tsx`), which uses a simple Tailwind **CSS grid** to provide add/remove rows + inline editing. ## Recommended approach (best UX / moderate scope) — **net +~320 LoC (product code)** ### UX proposal - Replace the textarea with a **Headers** editor that looks/behaves like a small table: | Header name | Value type | Value | | |---|---|---|---| | `Authorization` | `Secret` / `Text` | secret picker or text input | delete | - Features: - **Add/remove rows** (`+ Add header`) - **Value type toggle** per row: - **Text** → stores `"value"` - **Secret** → stores `{ secret: "SECRET_NAME" }` - **Secret picker** based on project secrets (plus freeform entry for custom secret names) - **Inline validation** (no more “JSON parse error” banner): - Duplicate header names (case-insensitive) - Empty name/value rows are ignored on save/test - Newlines in header values rejected - Secret key missing from project secrets → warning (still allow save if user wants; test will fail anyway) - Optional: an **“Advanced: JSON”** collapsible showing the generated JSON (read-only by default; editable only if we want to support paste/import) ### Implementation steps 1. **Introduce a reusable editor component** - Add `MCPHeadersEditor` (new file, likely under `src/browser/components/` or `src/browser/components/Settings/components/`). - Internally model rows as: - `type HeaderRow = { id: string; name: string; kind: "text" | "secret"; value: string }` - Provide helper conversions: - `rowsToHeadersRecord(rows) => Record<string, MCPHeaderValue> | undefined` - `headersRecordToRows(headers) => HeaderRow[]` - Return both: - `headers` (converted record) for parent to pass to API - `validation` info (so parent can disable **Test/Add/Save** when invalid) 2. **Load project secrets for the secret picker** - In `ProjectSettingsSection.tsx`, use `useProjectContext().getSecrets(selectedProject)`. - Keep `projectSecrets: Secret[]` in state; refresh when selected project changes. 3. **Update Add server flow** - Replace `headersJson: string` in `EditableServer` with `headersRows: HeaderRow[]`. - Swap the textarea for `<MCPHeadersEditor … />`. - On **Test** / **Add**: - Convert rows → record and pass `headers` to `api.projects.mcp.test/add`. - If validation fails, show inline error and do not call the API. 4. **Update Edit existing server flow** - On `handleStartEdit`, initialize `headersRows` from `entry.headers`. - Replace the edit-mode textarea with the same editor. - On **Save**, use the same row → record conversion. 5. **Polish / discoverability** - Rename label from **“Headers (JSON)”** → **“HTTP headers (optional)”**. - In non-edit display, show a small summary like `Headers: 2` (optional tooltip listing keys). - Add helper text nudging users to secrets for auth tokens. ### Tests / validation - Add unit tests for the conversion + validation helpers (pure logic): - Round-trip conversion (`record → rows → record`) - Secret rows convert to `{ secret: … }` - Duplicate-key detection is case-insensitive - Newline rejection ### Manual QA checklist - Add server (HTTP/SSE/Auto): add headers, switch Text/Secret, test, add. - Edit existing server: headers load correctly, save works. - Existing configs with JSON headers continue working and render in the table. - Missing secret key: editor shows warning; “Test” fails with clear error. --- ## Alternative approaches <details> <summary>Option A (simpler, fewer features) — net +~200 LoC</summary> - Table editor only supports `string` values. - Keep the old JSON textarea behind “Advanced” for `{ secret: … }` use-cases. - Pros: faster to ship. - Cons: users still need JSON for the most common auth use-case. </details> <details> <summary>Option B (more ambitious) — net +~420 LoC</summary> - Extract a generic `KeyValueGridEditor` from `SecretsModal.tsx`. - Reuse it for both Secrets and MCP Headers (reduces duplicate patterns long-term). - Pros: cleaner component library. - Cons: bigger refactor; more surface area for regression. </details> </details> --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_ --------- Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 21848f5 commit fead02c

File tree

6 files changed

+642
-87
lines changed

6 files changed

+642
-87
lines changed

.storybook/mocks/orpc.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
ProvidersConfigMap,
1212
WorkspaceStatsSnapshot,
1313
} from "@/common/orpc/types";
14+
import type { Secret } from "@/common/types/secrets";
1415
import type { ChatStats } from "@/common/types/chatStats";
1516
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
1617
import {
@@ -87,6 +88,8 @@ export interface MockORPCClientOptions {
8788
/** Session usage data per workspace (for Costs tab) */
8889
workspaceStatsSnapshots?: Map<string, WorkspaceStatsSnapshot>;
8990
statsTabVariant?: "control" | "stats";
91+
/** Project secrets per project */
92+
projectSecrets?: Map<string, Secret[]>;
9093
sessionUsage?: Map<string, MockSessionUsage>;
9194
/** MCP server configuration per project */
9295
mcpServers?: Map<
@@ -132,6 +135,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
132135
sessionUsage = new Map(),
133136
workspaceStatsSnapshots = new Map<string, WorkspaceStatsSnapshot>(),
134137
statsTabVariant = "control",
138+
projectSecrets = new Map<string, Secret[]>(),
135139
mcpServers = new Map(),
136140
mcpOverrides = new Map(),
137141
mcpTestResults = new Map(),
@@ -229,8 +233,12 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
229233
return { success: true, data: undefined };
230234
},
231235
secrets: {
232-
get: async () => [],
233-
update: async () => ({ success: true, data: undefined }),
236+
get: async (input: { projectPath: string }) =>
237+
projectSecrets.get(input.projectPath) ?? [],
238+
update: async (input: { projectPath: string; secrets: Secret[] }) => {
239+
projectSecrets.set(input.projectPath, input.secrets);
240+
return { success: true, data: undefined };
241+
},
234242
},
235243
mcp: {
236244
list: async (input: { projectPath: string }) => mcpServers.get(input.projectPath) ?? {},
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import React from "react";
2+
import { ChevronDown } from "lucide-react";
3+
import { Popover, PopoverContent, PopoverTrigger } from "@/browser/components/ui/popover";
4+
import { ToggleGroup, ToggleGroupItem } from "@/browser/components/ui/toggle-group";
5+
import {
6+
createMCPHeaderRow,
7+
mcpHeaderRowsToRecord,
8+
type MCPHeaderRow,
9+
} from "@/browser/utils/mcpHeaders";
10+
11+
export const MCPHeadersEditor: React.FC<{
12+
rows: MCPHeaderRow[];
13+
onChange: (rows: MCPHeaderRow[]) => void;
14+
secretKeys: string[];
15+
disabled?: boolean;
16+
}> = (props) => {
17+
const [openSecretPickerRowId, setOpenSecretPickerRowId] = React.useState<string | null>(null);
18+
const sortedSecretKeys = props.secretKeys
19+
.slice()
20+
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
21+
const { validation } = mcpHeaderRowsToRecord(props.rows, {
22+
knownSecretKeys: new Set(props.secretKeys),
23+
});
24+
25+
const addRow = () => {
26+
props.onChange([...props.rows, createMCPHeaderRow()]);
27+
};
28+
29+
const removeRow = (id: string) => {
30+
props.onChange(props.rows.filter((row) => row.id !== id));
31+
if (openSecretPickerRowId === id) {
32+
setOpenSecretPickerRowId(null);
33+
}
34+
};
35+
36+
const updateRow = (id: string, patch: Partial<Omit<MCPHeaderRow, "id">>) => {
37+
props.onChange(
38+
props.rows.map((row) => {
39+
if (row.id !== id) {
40+
return row;
41+
}
42+
const next: MCPHeaderRow = {
43+
...row,
44+
...patch,
45+
};
46+
47+
// If they flip kind, keep value but allow the placeholder/suggestions to change.
48+
return next;
49+
})
50+
);
51+
};
52+
53+
return (
54+
<div className="space-y-2">
55+
{props.rows.length === 0 ? (
56+
<div className="text-muted border-border-medium rounded-md border border-dashed px-3 py-3 text-center text-xs">
57+
No headers configured
58+
</div>
59+
) : (
60+
<div className="[&>label]:text-muted grid grid-cols-[1fr_auto_1fr_auto] items-end gap-1 [&>label]:mb-0.5 [&>label]:text-[11px]">
61+
<label>Header</label>
62+
<label>Type</label>
63+
<label>Value</label>
64+
<div />
65+
66+
{props.rows.map((row) => (
67+
<React.Fragment key={row.id}>
68+
<input
69+
type="text"
70+
value={row.name}
71+
onChange={(e) => updateRow(row.id, { name: e.target.value })}
72+
placeholder="Authorization"
73+
disabled={props.disabled}
74+
spellCheck={false}
75+
className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim text-foreground w-full rounded border px-2.5 py-1.5 font-mono text-[13px] focus:outline-none disabled:opacity-50"
76+
/>
77+
78+
<ToggleGroup
79+
type="single"
80+
value={row.kind}
81+
onValueChange={(value) => {
82+
if (value !== "text" && value !== "secret") {
83+
return;
84+
}
85+
86+
updateRow(row.id, { kind: value });
87+
88+
if (value !== "secret" && openSecretPickerRowId === row.id) {
89+
setOpenSecretPickerRowId(null);
90+
}
91+
}}
92+
size="sm"
93+
disabled={props.disabled}
94+
className="h-[34px]"
95+
>
96+
<ToggleGroupItem value="text" size="sm" className="h-[26px] px-3 text-[13px]">
97+
Text
98+
</ToggleGroupItem>
99+
<ToggleGroupItem value="secret" size="sm" className="h-[26px] px-3 text-[13px]">
100+
Secret
101+
</ToggleGroupItem>
102+
</ToggleGroup>
103+
104+
{row.kind === "secret" ? (
105+
<div className="flex items-stretch gap-1">
106+
<input
107+
type="text"
108+
value={row.value}
109+
onChange={(e) => updateRow(row.id, { value: e.target.value })}
110+
placeholder="MCP_TOKEN"
111+
disabled={props.disabled}
112+
spellCheck={false}
113+
className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim text-foreground w-full flex-1 rounded border px-2.5 py-1.5 font-mono text-[13px] focus:outline-none disabled:opacity-50"
114+
/>
115+
116+
{sortedSecretKeys.length > 0 && (
117+
<Popover
118+
open={openSecretPickerRowId === row.id}
119+
onOpenChange={(open) => setOpenSecretPickerRowId(open ? row.id : null)}
120+
>
121+
<PopoverTrigger asChild>
122+
<button
123+
type="button"
124+
aria-label="Choose secret"
125+
title="Choose secret"
126+
disabled={props.disabled}
127+
className="bg-modal-bg border-border-medium focus:border-accent hover:bg-hover text-muted hover:text-foreground flex cursor-pointer items-center justify-center rounded border px-2.5 py-1.5 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
128+
>
129+
<ChevronDown className="h-4 w-4" />
130+
</button>
131+
</PopoverTrigger>
132+
<PopoverContent align="end" sideOffset={4} className="p-1">
133+
<div className="max-h-48 overflow-auto">
134+
{(row.value.trim() === ""
135+
? sortedSecretKeys
136+
: sortedSecretKeys.filter((key) =>
137+
key.toLowerCase().includes(row.value.trim().toLowerCase())
138+
)
139+
).map((key) => (
140+
<button
141+
key={key}
142+
type="button"
143+
onClick={() => {
144+
updateRow(row.id, { value: key });
145+
setOpenSecretPickerRowId(null);
146+
}}
147+
className="hover:bg-hover text-foreground w-full cursor-pointer rounded px-2 py-1 text-left font-mono text-xs"
148+
>
149+
{key}
150+
</button>
151+
))}
152+
</div>
153+
</PopoverContent>
154+
</Popover>
155+
)}
156+
</div>
157+
) : (
158+
<input
159+
type="text"
160+
value={row.value}
161+
onChange={(e) => updateRow(row.id, { value: e.target.value })}
162+
placeholder="value"
163+
disabled={props.disabled}
164+
spellCheck={false}
165+
className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim text-foreground w-full rounded border px-2.5 py-1.5 font-mono text-[13px] focus:outline-none disabled:opacity-50"
166+
/>
167+
)}
168+
169+
<button
170+
type="button"
171+
onClick={() => removeRow(row.id)}
172+
disabled={props.disabled}
173+
className="text-danger-light border-danger-light hover:bg-danger-light/10 cursor-pointer rounded border bg-transparent px-2.5 py-1.5 text-[13px] transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50"
174+
title="Remove header"
175+
>
176+
×
177+
</button>
178+
</React.Fragment>
179+
))}
180+
</div>
181+
)}
182+
183+
{validation.errors.length > 0 && (
184+
<div className="bg-destructive/10 text-destructive rounded-md px-3 py-2 text-xs">
185+
{validation.errors.map((msg, i) => (
186+
<div key={i}>{msg}</div>
187+
))}
188+
</div>
189+
)}
190+
191+
{validation.errors.length === 0 && validation.warnings.length > 0 && (
192+
<div className="text-muted rounded-md px-1 text-xs">
193+
{validation.warnings.map((msg, i) => (
194+
<div key={i}>{msg}</div>
195+
))}
196+
</div>
197+
)}
198+
199+
<button
200+
type="button"
201+
onClick={addRow}
202+
disabled={props.disabled}
203+
className="text-muted border-border-medium hover:bg-hover hover:border-border-darker hover:text-foreground w-full cursor-pointer rounded border border-dashed bg-transparent px-3 py-2 text-[13px] transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50"
204+
>
205+
+ Add header
206+
</button>
207+
</div>
208+
);
209+
};

0 commit comments

Comments
 (0)