feat(web): custom headers on the manual server-config form (#1915)

Adding a server by hand offered no way to set custom HTTP headers, so the
only route to a cookie or a routing header was importing a config file.
The manual "Add server" form now carries a Custom Headers key/value editor
for the sse / streamable-http transports, pre-populated when editing or
cloning and submitted alongside the config.

Headers are not part of `MCPServerConfig` — they live on the entry's
`settings` — so they travel as `onSubmit`'s third argument rather than
folded into the config, and `addServer` / `updateServer` gained an optional
`settings` parameter to carry them to the backend (`POST /api/servers`
already accepted one). `updateServer`'s omission semantics are unchanged:
with no settings passed, the route still preserves the node on disk.

`KeyValueRows` moves out of ServerSettingsForm into a shared element so the
two editors cannot drift, and each row's controls gain a row-scoped
`aria-label` ("header value, Cookie", "Remove header, Cookie") — previously
every remove button announced only "X", indistinguishable across rows.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 20:57:02 -04:00
commit 9aab945964

View file

@ -0,0 +1,91 @@
import { ActionIcon, Group, TextInput } from "@mantine/core";
import { ClearButton } from "../ClearButton/ClearButton";
export interface KeyValuePair {
key: string;
value: string;
}
export interface KeyValueRowsProps {
items: KeyValuePair[];
/**
* Singular noun for one row ("header", "environment variable", …). Used only
* to build each control's `aria-label`: the section heading and the "Key" /
* "Value" placeholders are not programmatically associated with the inputs,
* so without it an assistive technology cannot tell one list's key box from
* another's, and the remove button announces only "X".
*/
entityLabel: string;
onChange: (index: number, key: string, value: string) => void;
onRemove: (index: number) => void;
}
// Optional (non-required) clearable field — keeps the ClearButton clickable.
const ClearableTextInput = TextInput.withProps({
rightSectionPointerEvents: "auto",
});
const RemoveIcon = ActionIcon.withProps({
color: "red",
variant: "subtle",
});
/**
* Controlled editor for a list of `{ key, value }` pairs — the shape the
* Inspector persists for custom headers, request metadata, and stdio
* environment variables. Owns no state: every keystroke is reported through
* `onChange(index, key, value)` and the caller re-renders with the new list.
*
* Shared by ServerSettingsForm (headers / metadata / env) and ServerConfigModal
* (headers on the manual add form, #1915) so the two cannot drift.
*/
export function KeyValueRows({
items,
entityLabel,
onChange,
onRemove,
}: KeyValueRowsProps) {
return (
<>
{items.map((item, index) => {
const rowLabel = item.key.trim() || `row ${index + 1}`;
return (
<Group key={index} grow>
<ClearableTextInput
placeholder="Key"
aria-label={`${entityLabel} name, ${rowLabel}`}
value={item.key}
onChange={(e) =>
onChange(index, e.currentTarget.value, item.value)
}
rightSection={
item.key ? (
<ClearButton
onClick={() => onChange(index, "", item.value)}
/>
) : null
}
/>
<ClearableTextInput
placeholder="Value"
aria-label={`${entityLabel} value, ${rowLabel}`}
value={item.value}
onChange={(e) => onChange(index, item.key, e.currentTarget.value)}
rightSection={
item.value ? (
<ClearButton onClick={() => onChange(index, item.key, "")} />
) : null
}
/>
<RemoveIcon
aria-label={`Remove ${entityLabel}, ${rowLabel}`}
onClick={() => onRemove(index)}
>
X
</RemoveIcon>
</Group>
);
})}
</>
);
}