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:
parent
6bc355df63
commit
9aab945964
2 changed files with 732 additions and 136 deletions
|
|
@ -329,8 +329,9 @@ describe("ServerSettingsForm", () => {
|
|||
expandedSections={["headers"]}
|
||||
/>,
|
||||
);
|
||||
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove header, Authorization" }),
|
||||
);
|
||||
expect(onRemoveHeader).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
|
|
@ -351,8 +352,9 @@ describe("ServerSettingsForm", () => {
|
|||
await user.type(valueInput, "Z");
|
||||
expect(onMetadataChange).toHaveBeenCalled();
|
||||
|
||||
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove metadata entry, userId" }),
|
||||
);
|
||||
expect(onRemoveMetadata).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
|
|
@ -676,9 +678,13 @@ describe("ServerSettingsForm", () => {
|
|||
await user.type(keyInput, "2");
|
||||
expect(onEnvChange).toHaveBeenLastCalledWith(0, "API_KEY2", "secret");
|
||||
|
||||
// The remove ("X") button sits alongside the row's key/value inputs.
|
||||
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
||||
await user.click(removeButtons[removeButtons.length - 1]!);
|
||||
// The remove button sits alongside the row's key/value inputs; its
|
||||
// accessible name identifies which row it belongs to.
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove environment variable, API_KEY",
|
||||
}),
|
||||
);
|
||||
expect(onRemoveEnv).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import { KeyValueRows } from "../../elements/KeyValueRows/KeyValueRows";
|
||||
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
|
|
@ -223,53 +224,6 @@ const ClearStoredOAuthHint = Text.withProps({
|
|||
miw: "12rem",
|
||||
});
|
||||
|
||||
function KeyValueRows({
|
||||
items,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
items: { key: string; value: string }[];
|
||||
onChange: (index: number, key: string, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
/* v8 ignore next 3 -- unreachable: every caller guards with `length === 0`
|
||||
and renders an EmptyHint instead, so KeyValueRows is only mounted with
|
||||
a non-empty list. */
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<Group key={index} grow>
|
||||
<ClearableTextInput
|
||||
placeholder="Key"
|
||||
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"
|
||||
value={item.value}
|
||||
onChange={(e) => onChange(index, item.key, e.currentTarget.value)}
|
||||
rightSection={
|
||||
item.value ? (
|
||||
<ClearButton onClick={() => onChange(index, item.key, "")} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Reserved-key rejection is inline per row (#2018): the reason is passed as the
|
||||
// key input's `error` **string**, so Mantine renders it in the input's own error
|
||||
// slot and wires the `aria-describedby` association — a bare boolean would mark
|
||||
|
|
@ -641,6 +595,7 @@ export function ServerSettingsForm({
|
|||
) : (
|
||||
<KeyValueRows
|
||||
items={settings.env}
|
||||
entityLabel="environment variable"
|
||||
onChange={onEnvChange}
|
||||
onRemove={onRemoveEnv}
|
||||
/>
|
||||
|
|
@ -667,6 +622,7 @@ export function ServerSettingsForm({
|
|||
) : (
|
||||
<KeyValueRows
|
||||
items={settings.headers}
|
||||
entityLabel="header"
|
||||
onChange={onHeaderChange}
|
||||
onRemove={onRemoveHeader}
|
||||
/>
|
||||
|
|
@ -690,6 +646,7 @@ export function ServerSettingsForm({
|
|||
) : (
|
||||
<KeyValueRows
|
||||
items={settings.metadata}
|
||||
entityLabel="metadata entry"
|
||||
onChange={onMetadataChange}
|
||||
onRemove={onRemoveMetadata}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue