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,60 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { KeyValueRows } from "./KeyValueRows";
const meta: Meta<typeof KeyValueRows> = {
title: "Elements/KeyValueRows",
component: KeyValueRows,
args: {
entityLabel: "header",
onChange: fn(),
onRemove: fn(),
},
};
export default meta;
type Story = StoryObj<typeof KeyValueRows>;
// Each row's controls carry a row-scoped accessible name, so a screen reader
// can tell one row's key box from another's.
export const Populated: Story = {
args: {
items: [
{ key: "Cookie", value: "branch=feature-x" },
{ key: "X-Env", value: "dev" },
],
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const value = await canvas.findByRole("textbox", {
name: "header value, Cookie",
});
await expect(value).toHaveValue("branch=feature-x");
await userEvent.click(
canvas.getByRole("button", { name: "Remove header, X-Env" }),
);
await expect(args.onRemove).toHaveBeenCalledWith(1);
},
};
// A row whose key is still blank falls back to a positional name rather than
// announcing nothing.
export const BlankKeyRow: Story = {
args: { items: [{ key: "", value: "" }] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByRole("textbox", { name: "header name, row 1" }),
).toBeInTheDocument();
},
};
// An empty list renders nothing — callers draw their own empty-state hint.
export const Empty: Story = {
args: { items: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryAllByRole("textbox")).toHaveLength(0);
},
};