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
6 changed files with 732 additions and 136 deletions
|
|
@ -19,8 +19,8 @@ function InteractiveRender(args: ServerConfigModalProps) {
|
|||
args.onClose();
|
||||
updateArgs({ opened: false });
|
||||
}}
|
||||
onSubmit={async (id, config) => {
|
||||
await args.onSubmit(id, config);
|
||||
onSubmit={async (id, config, headers) => {
|
||||
await args.onSubmit(id, config, headers);
|
||||
updateArgs({ opened: false });
|
||||
}}
|
||||
/>
|
||||
|
|
@ -134,7 +134,39 @@ export const EditSse: Story = {
|
|||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialog = within(await findDialog(body, "Edit server"));
|
||||
await expect(await dialog.findByLabelText(/^URL/)).toBeInTheDocument();
|
||||
// Headers are no longer entered here — they live in ServerSettingsForm.
|
||||
await expect(dialog.queryByLabelText(/Headers/i)).toBeNull();
|
||||
// The headers editor starts collapsed to a bare "+ Add Header" control
|
||||
// when the server has none stored.
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "+ Add Header" }),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
dialog.queryAllByRole("textbox", { name: /header name/ }),
|
||||
).toHaveLength(0);
|
||||
},
|
||||
};
|
||||
|
||||
// Custom headers on the manual form (#1915) — the reason the field exists is a
|
||||
// cookie routing requests to one developer's branch on a shared dev server.
|
||||
export const EditSseWithHeaders: Story = {
|
||||
args: {
|
||||
mode: "edit",
|
||||
initialId: "remote",
|
||||
initialConfig: sseConfig,
|
||||
initialHeaders: [{ key: "Cookie", value: "branch=feature-x" }],
|
||||
existingIds: [],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialog = within(await findDialog(body, "Edit server"));
|
||||
const value = (await dialog.findByRole("textbox", {
|
||||
name: "header value, Cookie",
|
||||
})) as HTMLInputElement;
|
||||
await expect(value.value).toBe("branch=feature-x");
|
||||
|
||||
// Adding a row appends an empty pair, named by position until it is keyed.
|
||||
await userEvent.click(dialog.getByRole("button", { name: "+ Add Header" }));
|
||||
await expect(
|
||||
await dialog.findByRole("textbox", { name: "header name, row 2" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -66,11 +66,11 @@ describe("ServerConfigModal", () => {
|
|||
await user.click(screen.getByRole("button", { name: /^Add$/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
|
||||
expect(props.onSubmit).toHaveBeenCalledWith("alpha", {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["x.js", "--port=3000"],
|
||||
});
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
"alpha",
|
||||
{ type: "stdio", command: "node", args: ["x.js", "--port=3000"] },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a command for stdio submission", async () => {
|
||||
|
|
@ -117,7 +117,7 @@ describe("ServerConfigModal", () => {
|
|||
expect(screen.queryByLabelText(/^Command/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits an sse config with just the url (headers move to the settings form)", async () => {
|
||||
it("submits an sse config with just the url and no headers", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const props = base();
|
||||
renderWithMantine(
|
||||
|
|
@ -133,10 +133,11 @@ describe("ServerConfigModal", () => {
|
|||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
|
||||
expect(props.onSubmit).toHaveBeenCalledWith("remote", {
|
||||
type: "sse",
|
||||
url: "https://x.test/sse",
|
||||
});
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
"remote",
|
||||
{ type: "sse", url: "https://x.test/sse" },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("submits a streamable-http config", async () => {
|
||||
|
|
@ -152,10 +153,11 @@ describe("ServerConfigModal", () => {
|
|||
);
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
|
||||
expect(props.onSubmit).toHaveBeenCalledWith("http-srv", {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
});
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
"http-srv",
|
||||
{ type: "streamable-http", url: "https://x.test/mcp" },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("loads the url from a streamable-http config", () => {
|
||||
|
|
@ -275,11 +277,11 @@ describe("ServerConfigModal", () => {
|
|||
await user.click(screen.getByRole("button", { name: /^Add$/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
|
||||
expect(props.onSubmit).toHaveBeenCalledWith("alpha", {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
cwd: "/tmp/cwd",
|
||||
});
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
"alpha",
|
||||
{ type: "stdio", command: "node", cwd: "/tmp/cwd" },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the Server ID field via its Clear button", async () => {
|
||||
|
|
@ -470,4 +472,144 @@ describe("ServerConfigModal", () => {
|
|||
await user.click(screen.getByRole("button", { name: /Cancel/ }));
|
||||
expect(props.onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("custom headers (#1915)", () => {
|
||||
it("hides the headers section for stdio", () => {
|
||||
renderWithMantine(<ServerConfigModal {...base()} />);
|
||||
expect(screen.queryByText("Custom headers")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Every keystroke re-renders the whole modal, and this case types four
|
||||
// fields, so it runs several times slower under the coverage project's v8
|
||||
// instrumentation than in a plain unit run — enough to trip the 5s default.
|
||||
// The extra ceiling is headroom for the instrumented run, not a hang guard.
|
||||
const TYPING_HEAVY_TIMEOUT_MS = 20000;
|
||||
|
||||
it(
|
||||
"submits headers added on the manual add form",
|
||||
async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const props = base();
|
||||
// Seeded on the http transport rather than driven through the
|
||||
// Transport select — that select has its own test above, and walking
|
||||
// its combobox here only added to the cost described above.
|
||||
renderWithMantine(
|
||||
<ServerConfigModal
|
||||
{...props}
|
||||
initialConfig={{ type: "streamable-http", url: "" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText(/Server ID/i), "remote");
|
||||
await user.type(screen.getByLabelText(/^URL/), "https://x.test/mcp");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "+ Add Header" }));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /header name, row 1/ }),
|
||||
"Cookie",
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /header value, Cookie/ }),
|
||||
"branch=feature-x",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /^Add$/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
"remote",
|
||||
{ type: "streamable-http", url: "https://x.test/mcp" },
|
||||
[{ key: "Cookie", value: "branch=feature-x" }],
|
||||
);
|
||||
},
|
||||
TYPING_HEAVY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("pre-populates the rows from initialHeaders and submits an edit", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const props = base();
|
||||
renderWithMantine(
|
||||
<ServerConfigModal
|
||||
{...props}
|
||||
mode="edit"
|
||||
initialId="remote"
|
||||
initialConfig={{ type: "sse", url: "https://x.test/sse" }}
|
||||
initialHeaders={[{ key: "X-Env", value: "dev" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueInput = screen.getByRole("textbox", {
|
||||
name: /header value, X-Env/,
|
||||
});
|
||||
expect(valueInput).toHaveValue("dev");
|
||||
await user.type(valueInput, "-2");
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
"remote",
|
||||
{ type: "sse", url: "https://x.test/sse" },
|
||||
[{ key: "X-Env", value: "dev-2" }],
|
||||
);
|
||||
});
|
||||
|
||||
it("drops blank-key rows and removes a row on X", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const props = base();
|
||||
renderWithMantine(
|
||||
<ServerConfigModal
|
||||
{...props}
|
||||
mode="edit"
|
||||
initialId="remote"
|
||||
initialConfig={{ type: "sse", url: "https://x.test/sse" }}
|
||||
initialHeaders={[
|
||||
{ key: "Keep", value: "1" },
|
||||
{ key: "Drop", value: "2" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// An empty row is the form's "still typing" placeholder — it must not
|
||||
// reach the caller.
|
||||
await user.click(screen.getByRole("button", { name: "+ Add Header" }));
|
||||
// Editing one row of several must leave its siblings untouched.
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "header value, Keep" }),
|
||||
"9",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove header, Drop" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
"remote",
|
||||
{ type: "sse", url: "https://x.test/sse" },
|
||||
[{ key: "Keep", value: "19" }],
|
||||
);
|
||||
});
|
||||
|
||||
it("submits no headers for stdio even when initialHeaders is set", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const props = base();
|
||||
renderWithMantine(
|
||||
<ServerConfigModal
|
||||
{...props}
|
||||
mode="edit"
|
||||
initialId="local"
|
||||
initialConfig={{ type: "stdio", command: "node" }}
|
||||
initialHeaders={[{ key: "X-Env", value: "dev" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce());
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
"local",
|
||||
{ type: "stdio", command: "node" },
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import {
|
|||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import {
|
||||
KeyValueRows,
|
||||
type KeyValuePair,
|
||||
} from "../../elements/KeyValueRows/KeyValueRows";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
|
|
@ -28,11 +32,27 @@ export interface ServerConfigModalProps {
|
|||
initialId?: string;
|
||||
/** When editing or cloning, the existing config to pre-populate. */
|
||||
initialConfig?: MCPServerConfig;
|
||||
/**
|
||||
* Custom HTTP headers already stored for the target server, pre-populated
|
||||
* when editing or cloning. Headers are not part of `MCPServerConfig` — they
|
||||
* live on the entry's `settings`, so they arrive (and leave, via `onSubmit`)
|
||||
* as their own value rather than folded into the config. (#1915)
|
||||
*/
|
||||
initialHeaders?: KeyValuePair[];
|
||||
/** Ids already in use — drives the uniqueness check (caller excludes the
|
||||
* target id from this list when in 'edit' mode). */
|
||||
existingIds: string[];
|
||||
onClose: () => void;
|
||||
onSubmit: (id: string, config: MCPServerConfig) => Promise<void> | void;
|
||||
/**
|
||||
* `headers` carries the edited custom-header rows (blank-key rows already
|
||||
* dropped). It is always `[]` for stdio, which has no HTTP request to attach
|
||||
* headers to.
|
||||
*/
|
||||
onSubmit: (
|
||||
id: string,
|
||||
config: MCPServerConfig,
|
||||
headers: KeyValuePair[],
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
type TransportChoice = "stdio" | "sse" | "streamable-http";
|
||||
|
|
@ -47,6 +67,8 @@ interface FormState {
|
|||
cwd: string;
|
||||
// sse / streamable-http
|
||||
url: string;
|
||||
/** Custom HTTP headers — sse / streamable-http only. (#1915) */
|
||||
headers: KeyValuePair[];
|
||||
}
|
||||
|
||||
// The `string`-valued FormState keys, which all share the same text-input
|
||||
|
|
@ -84,6 +106,10 @@ const EnvTextarea = Textarea.withProps({
|
|||
minRows: 2,
|
||||
rightSectionPointerEvents: "auto",
|
||||
});
|
||||
const AddHeaderButton = Button.withProps({ size: "xs", variant: "light" });
|
||||
const HeadersHeader = Group.withProps({ justify: "space-between", gap: "sm" });
|
||||
const HeadersLabel = Text.withProps({ size: "sm", fw: 500 });
|
||||
const HeadersHint = Text.withProps({ size: "xs", c: "dimmed" });
|
||||
|
||||
const MODE_TITLES: Record<ServerConfigModalMode, string> = {
|
||||
add: "Add server",
|
||||
|
|
@ -94,9 +120,14 @@ const MODE_TITLES: Record<ServerConfigModalMode, string> = {
|
|||
function configToFormState(
|
||||
initialId: string | undefined,
|
||||
initialConfig: MCPServerConfig | undefined,
|
||||
initialHeaders: KeyValuePair[] | undefined,
|
||||
mode: ServerConfigModalMode,
|
||||
): FormState {
|
||||
const id = mode === "edit" ? (initialId ?? "") : "";
|
||||
// Copy the rows rather than aliasing the caller's array — the form mutates
|
||||
// this list as its own state, and a clone keeps a cancelled edit from
|
||||
// reaching the entry the caller passed in.
|
||||
const headers = (initialHeaders ?? []).map((h) => ({ ...h }));
|
||||
const transport: TransportChoice =
|
||||
initialConfig?.type === undefined ? "stdio" : initialConfig.type;
|
||||
if (!initialConfig) {
|
||||
|
|
@ -108,6 +139,7 @@ function configToFormState(
|
|||
envText: "",
|
||||
cwd: "",
|
||||
url: "",
|
||||
headers,
|
||||
};
|
||||
}
|
||||
if (transport === "stdio") {
|
||||
|
|
@ -122,9 +154,9 @@ function configToFormState(
|
|||
.join("\n"),
|
||||
cwd: c.cwd ?? "",
|
||||
url: "",
|
||||
headers,
|
||||
};
|
||||
}
|
||||
// sse / streamable-http — custom headers live in ServerSettingsForm now.
|
||||
const url =
|
||||
initialConfig.type === "sse" || initialConfig.type === "streamable-http"
|
||||
? initialConfig.url
|
||||
|
|
@ -137,6 +169,7 @@ function configToFormState(
|
|||
envText: "",
|
||||
cwd: "",
|
||||
url: url ?? "",
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -185,13 +218,14 @@ export function ServerConfigModal({
|
|||
mode,
|
||||
initialId,
|
||||
initialConfig,
|
||||
initialHeaders,
|
||||
existingIds,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ServerConfigModalProps) {
|
||||
const initial = useMemo(
|
||||
() => configToFormState(initialId, initialConfig, mode),
|
||||
[initialId, initialConfig, mode],
|
||||
() => configToFormState(initialId, initialConfig, initialHeaders, mode),
|
||||
[initialId, initialConfig, initialHeaders, mode],
|
||||
);
|
||||
const [form, setForm] = useState<FormState>(initial);
|
||||
const [submitError, setSubmitError] = useState<string | undefined>(undefined);
|
||||
|
|
@ -210,6 +244,22 @@ export function ServerConfigModal({
|
|||
const clearTextField = (field: TextField) => () =>
|
||||
setForm((f) => ({ ...f, [field]: "" }));
|
||||
|
||||
// Header rows are a pair array rather than a plain string, so they get their
|
||||
// own handlers instead of riding `setTextField` (which `TextField` excludes
|
||||
// by type).
|
||||
const addHeader = () =>
|
||||
setForm((f) => ({ ...f, headers: [...f.headers, { key: "", value: "" }] }));
|
||||
const removeHeader = (index: number) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
headers: f.headers.filter((_, i) => i !== index),
|
||||
}));
|
||||
const changeHeader = (index: number, key: string, value: string) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
headers: f.headers.map((h, i) => (i === index ? { key, value } : h)),
|
||||
}));
|
||||
|
||||
// Reset the form whenever the modal opens, or whenever `initial` changes
|
||||
// while it is open. Keying on `opened ? initial : undefined` collapses both
|
||||
// triggers into one value: it flips to `initial` on open, tracks `initial`
|
||||
|
|
@ -263,9 +313,9 @@ export function ServerConfigModal({
|
|||
return { ok: false, error: "URL is required for sse / streamable-http." };
|
||||
}
|
||||
const base = { url: form.url.trim() };
|
||||
// Custom headers live in ServerSettingsForm now (persisted under
|
||||
// settings.headers on the entry); the SSE / streamable-http config here
|
||||
// only carries the canonical transport fields.
|
||||
// Custom headers are persisted under `settings.headers` on the entry, not
|
||||
// on the config — they leave through `onSubmit`'s third argument, so the
|
||||
// SSE / streamable-http config here carries only transport fields.
|
||||
const config: MCPServerConfig =
|
||||
form.transport === "sse"
|
||||
? { type: "sse", ...base }
|
||||
|
|
@ -288,9 +338,19 @@ export function ServerConfigModal({
|
|||
setSubmitError(built.error);
|
||||
return;
|
||||
}
|
||||
// Blank-key rows are the form's placeholder for "row being typed" and mean
|
||||
// nothing on the wire — the persist layer drops them anyway, so drop them
|
||||
// here too and keep the caller's payload honest. stdio has no HTTP request
|
||||
// to carry headers, so it always submits none.
|
||||
const headers =
|
||||
form.transport === "stdio"
|
||||
? []
|
||||
: form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value }))
|
||||
.filter((h) => h.key.length > 0);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(trimmedId, built.config);
|
||||
await onSubmit(trimmedId, built.config, headers);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : String(err));
|
||||
|
|
@ -412,21 +472,43 @@ export function ServerConfigModal({
|
|||
/>
|
||||
</>
|
||||
) : (
|
||||
<RequiredTextInput
|
||||
label="URL"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={form.url}
|
||||
onChange={setTextField("url")}
|
||||
disabled={submitting}
|
||||
rightSection={
|
||||
form.url ? (
|
||||
<ClearButton
|
||||
disabled={submitting}
|
||||
onClick={clearTextField("url")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<RequiredTextInput
|
||||
label="URL"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={form.url}
|
||||
onChange={setTextField("url")}
|
||||
disabled={submitting}
|
||||
rightSection={
|
||||
form.url ? (
|
||||
<ClearButton
|
||||
disabled={submitting}
|
||||
onClick={clearTextField("url")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<FieldGrid>
|
||||
<HeadersHeader>
|
||||
<HeadersLabel>Custom headers</HeadersLabel>
|
||||
<AddHeaderButton onClick={addHeader} disabled={submitting}>
|
||||
+ Add Header
|
||||
</AddHeaderButton>
|
||||
</HeadersHeader>
|
||||
<HeadersHint>
|
||||
Sent with every HTTP request to this server — cookies
|
||||
included. If OAuth is configured later, the `Authorization`
|
||||
header is owned by the OAuth flow and any value set here is
|
||||
ignored.
|
||||
</HeadersHint>
|
||||
<KeyValueRows
|
||||
items={form.headers}
|
||||
entityLabel="header"
|
||||
onChange={changeHeader}
|
||||
onRemove={removeHeader}
|
||||
/>
|
||||
</FieldGrid>
|
||||
</>
|
||||
)}
|
||||
</FieldGrid>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -276,8 +276,9 @@ describe("ServerSettingsModal", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
|
||||
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove header, Authorization" }),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...initialSettings,
|
||||
headers: [],
|
||||
|
|
@ -342,10 +343,11 @@ describe("ServerSettingsModal", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
|
||||
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
||||
// After expanding metadata, both header and metadata X buttons exist;
|
||||
// the metadata X is the last one.
|
||||
await user.click(removeButtons[removeButtons.length - 1]);
|
||||
// Both the header and metadata rows have a remove button; each is named
|
||||
// for the row it belongs to, so no positional guess is needed.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove metadata entry, userId" }),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...initialSettings,
|
||||
metadata: [],
|
||||
|
|
@ -764,8 +766,9 @@ describe("ServerSettingsModal", () => {
|
|||
await user.click(
|
||||
screen.getByRole("button", { name: "Environment Variables" }),
|
||||
);
|
||||
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
||||
await user.click(removeButtons[removeButtons.length - 1]);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Remove environment variable, A" }),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...emptySettings,
|
||||
env: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue