inspector/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx
cliffhall b33532c024 feat: allow complex JSON values in request _meta
Closes #1910

`_meta` was typed `Record<string, string>` end to end in the Inspector —
on disk as a `{ key, value }[]` pair array, in the Server Settings form as
key/value rows, and in every request verb's per-call metadata parameter.
Nothing in the MCP spec restricts a `_meta` value to a string, and the SDK
models the field as a `z.looseObject`, so the restriction was ours alone.

Widen the whole path to `RequestMetadata` (`Record<string, JsonValue>`):

- Per-server default metadata is a JSON **object** on disk and in memory.
  The pre-#1910 pair array is still read (`normalizeStoredMetadata`) so an
  existing `mcp.json` keeps working; it is never written back, so a save
  migrates the entry.
- The Server Settings → Request Metadata section is a JSON editor
  (`JsonObjectInput`) instead of key/value rows — rows cannot express an
  object value. It keeps the typed text as the display source and emits
  only parsed JSON, the same draft split `SchemaJsonField` uses, so the box
  stays typeable; invalid text is flagged inline and not applied.
- The CLI stops flattening `--metadata` / `--tool-metadata` through
  `metaValueToString`. Those values were already JSON-parsed, so
  `trace={"id":"abc"}` used to reach the wire as the *string* `{"id":"abc"}`
  and now reaches it as an object.
- `servers/show` redaction replaces a sensitive key's whole value rather
  than a string, so a structured value cannot leak through its members.
- `progressTokenOf` narrows the reserved `progressToken` key back to
  `string | number` before it reaches the SDK; any other JSON still travels
  inside `_meta` but wires up no progress callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:23:40 -04:00

873 lines
29 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ServerSettingsModal } from "./ServerSettingsModal";
const initialSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: { userId: "u-1" },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
oauthClientId: "cid",
oauthClientSecret: "secret",
oauthScopes: "read",
maxFetchRequests: 1000,
roots: [{ uri: "file:///project", name: "Project" }],
};
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
};
describe("ServerSettingsModal", () => {
it("does not render content when opened is false", () => {
renderWithMantine(
<ServerSettingsModal
opened={false}
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByText("Server Settings")).not.toBeInTheDocument();
});
it("renders the modal title and form when opened", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByText("Server Settings")).toBeInTheDocument();
expect(screen.getByText("Custom Headers")).toBeInTheDocument();
});
it("hides the OAuth Settings section for stdio servers", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByText("OAuth Settings")).not.toBeInTheDocument();
});
it("invokes onClose when the CloseButton is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
// Modal renders into a portal on document.body
const closeBtn = document.querySelector(
"button.mantine-CloseButton-root",
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
await user.click(closeBtn!);
expect(onClose).toHaveBeenCalled();
});
it("maps the OAuth insufficient-scope policy into settings (SEP-2350)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: /OAuth Settings/i }));
await user.click(
screen.getByRole("textbox", { name: /Insufficient-scope/i }),
);
await user.click(screen.getByText("Throw (surface the error)"));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ oauthOnInsufficientScope: "throw" }),
);
});
it("maps the selected protocol era into settings (#1626)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Options section (with Protocol Era) is expanded by default.
await user.click(screen.getByDisplayValue("Legacy (2025-11-25 handshake)"));
await user.click(screen.getByText("Modern (2026-07-28, sessionless)"));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ protocolEra: "modern" }),
);
});
it("maps the selected modern log level into settings (#1629)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
// The modern log-level control only shows for a modern-capable era.
settings={{ ...emptySettings, protocolEra: "modern" }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Options section (with Log Level per Request) is expanded by default;
// it defaults to "debug".
await user.click(screen.getAllByDisplayValue("debug")[0]);
await user.click(screen.getByText("Off (no logs)"));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ modernLogLevel: "off" }),
);
});
it("folds an advertised-extension toggle into settings.advertisedExtensions (#1739)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// Advertised Extensions is its own accordion section (#1747), collapsed by
// default — open it, then toggle Tasks (checked by the registry default) off.
await user.click(
screen.getByRole("button", { name: "Advertised Extensions" }),
);
await user.click(
screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
}),
);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
}),
);
});
it("reconverges to no override when an extension toggle returns to its default (#1739)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
// Start from a disabling override so re-checking Tasks returns it to the
// registry default (advertised) and should drop the key entirely.
settings={{
...emptySettings,
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
}}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// Open the Advertised Extensions section (#1747), then re-check Tasks.
await user.click(
screen.getByRole("button", { name: "Advertised Extensions" }),
);
await user.click(
screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
}),
);
// The only override returned to its default → the whole map drops to
// undefined (no-override), keeping the on-disk round-trip minimal.
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ advertisedExtensions: undefined }),
);
});
it("hides the modern log-level control when this server negotiated legacy under 'auto' (#1629)", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, protocolEra: "auto" }}
serverType="streamable-http"
isStdio={false}
negotiatedEra="legacy"
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByText("Log Level per Request")).toBeNull();
});
it("calls onSettingsChange when adding a header after expanding the section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
await user.click(screen.getByRole("button", { name: "+ Add Header" }));
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
headers: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing a header", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
await user.click(
screen.getByRole("button", {
name: "Remove header, Authorization, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
headers: [],
});
});
it("calls onSettingsChange with updated header value when typing", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
const valueInput = screen.getByDisplayValue("Bearer abc");
await user.type(valueInput, "1");
expect(onSettingsChange).toHaveBeenCalled();
const lastCall =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(lastCall.headers[0].key).toBe("Authorization");
expect(lastCall.headers[0].value).toContain("Bearer abc");
});
it("calls onSettingsChange when changing timeouts", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Timeouts" }));
const connInput = screen.getByLabelText(/Connection Timeout/);
await user.type(connInput, "5");
expect(onSettingsChange).toHaveBeenCalled();
const call = onSettingsChange.mock.calls[0][0];
expect(typeof call.connectionTimeout).toBe("number");
});
it("calls onSettingsChange when changing OAuth fields", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
const clientIdInput = screen.getByLabelText("Client ID");
await user.type(clientIdInput, "a");
expect(onSettingsChange).toHaveBeenCalled();
const call = onSettingsChange.mock.calls[0][0];
expect(call.oauthClientId).toBe("a");
});
it("calls onSettingsChange when toggling enterprise-managed authorization", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
await user.click(
screen.getByRole("checkbox", {
name: "Enterprise-managed authorization",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
oauthClientId: "",
oauthClientSecret: "",
oauthScopes: "",
oauthAuthorizationParams: [],
enterpriseManaged: true,
});
});
// #1906 — a typed endpoint override lands on the settings; clearing it
// persists `undefined` rather than an empty string, so "cleared" and "never
// set" are the same state on disk.
it("persists a typed endpoint override, and clears it back to undefined", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const { rerender } = renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
await user.type(
screen.getByRole("textbox", { name: /Token URL override/i }),
"h",
);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ oauthTokenUrl: "h" }),
);
onSettingsChange.mockClear();
rerender(
<ServerSettingsModal
opened
settings={{
...emptySettings,
oauthTokenUrl: "https://staging.test/token",
}}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
const clearButtons = screen.getAllByRole("button", { name: /clear/i });
await user.click(clearButtons[clearButtons.length - 1]);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ oauthTokenUrl: undefined }),
);
});
// #2018 — the authorization-parameter rows ride the same onOAuthChange
// callback, so the modal folds them onto the settings object.
it("persists an added authorization-parameter row onto the settings", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
await user.click(screen.getByRole("button", { name: "+ Add Parameter" }));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({
oauthAuthorizationParams: [{ key: "", value: "" }],
}),
);
});
it("calls onSettingsChange with a blank row when adding a root", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Roots" }));
await user.click(screen.getByRole("button", { name: "+ Add Root" }));
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
roots: [{ uri: "", name: "" }],
});
});
it("calls onSettingsChange when removing a root", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Roots" }));
const removeButtons = screen.getAllByRole("button", { name: "X" });
// The roots X is the last one (headers section is also expanded).
await user.click(removeButtons[removeButtons.length - 1]);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
roots: [],
});
});
it("calls onSettingsChange when editing a root uri/name", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Roots" }));
const uriInput = screen.getByDisplayValue("file:///project");
await user.type(uriInput, "/src");
expect(onSettingsChange).toHaveBeenCalled();
const lastCall =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(lastCall.roots[0].name).toBe("Project");
expect(lastCall.roots[0].uri).toContain("file:///project");
});
it("toggles all accordion sections when ListToggle button is clicked", async () => {
const user = userEvent.setup();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
const optionsControl = screen.getByRole("button", { name: "Options" });
const headersControl = screen.getByRole("button", {
name: "Custom Headers",
});
const metadataControl = screen.getByRole("button", {
name: "Request Metadata",
});
const timeoutsControl = screen.getByRole("button", { name: "Timeouts" });
const oauthControl = screen.getByRole("button", { name: "OAuth Settings" });
const rootsControl = screen.getByRole("button", { name: "Roots" });
// Initially only "options" is expanded.
expect(optionsControl.getAttribute("aria-expanded")).toBe("true");
expect(headersControl.getAttribute("aria-expanded")).toBe("false");
// Not every section is expanded → ListToggle starts in compact mode
// and exposes "Expand all" as its aria-label.
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(headersControl.getAttribute("aria-expanded")).toBe("true");
expect(metadataControl.getAttribute("aria-expanded")).toBe("true");
expect(timeoutsControl.getAttribute("aria-expanded")).toBe("true");
expect(oauthControl.getAttribute("aria-expanded")).toBe("true");
expect(rootsControl.getAttribute("aria-expanded")).toBe("true");
// After expanding every section the toggle flips to "Collapse all".
await user.click(screen.getByRole("button", { name: "Collapse all" }));
expect(optionsControl.getAttribute("aria-expanded")).toBe("false");
expect(headersControl.getAttribute("aria-expanded")).toBe("false");
});
it("calls onSettingsChange when toggling Auto Refresh in the Options section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Options section is expanded on open, so the checkbox is visible.
await user.click(
screen.getByRole("checkbox", {
name: /Auto Refresh on List Changed Notifications/,
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
autoRefreshOnListChanged: true,
});
});
it("calls onSettingsChange when changing the Network Log Size", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, maxFetchRequests: 1000 }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.type(screen.getByLabelText(/Network Log Size/), "2");
expect(onSettingsChange).toHaveBeenCalled();
const call = onSettingsChange.mock.calls.at(-1)?.[0];
expect(typeof call.maxFetchRequests).toBe("number");
});
it("preserves sibling rows when editing one of several headers", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const twoHeaders: InspectorServerSettings = {
...emptySettings,
headers: [
{ key: "A", value: "1" },
{ key: "B", value: "2" },
],
};
renderWithMantine(
<ServerSettingsModal
opened
settings={twoHeaders}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
// Editing the first header's value must leave the second row untouched
// (the `i === index ? … : h` non-matching branch).
await user.type(screen.getByDisplayValue("1"), "0");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.headers[1]).toEqual({ key: "B", value: "2" });
});
it("replaces the whole metadata object when the JSON editor changes", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
const editor = screen.getByLabelText("Request metadata JSON");
await user.clear(editor);
await user.type(editor, '{{"trace":{{"id":"abc"}}}');
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
// The nested object arrives as an object, not a stringified one — the
// whole point of #1910.
expect(lastCall.metadata).toEqual({ trace: { id: "abc" } });
// Every other setting is carried through untouched.
expect(lastCall.headers).toEqual(emptySettings.headers);
});
it("preserves sibling rows when editing one of several roots", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const twoRoots: InspectorServerSettings = {
...emptySettings,
roots: [
{ uri: "file:///a", name: "A" },
{ uri: "file:///b", name: "B" },
],
};
renderWithMantine(
<ServerSettingsModal
opened
settings={twoRoots}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Roots" }));
await user.type(screen.getByDisplayValue("file:///a"), "1");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.roots[1]).toEqual({ uri: "file:///b", name: "B" });
});
describe("stdio env / cwd handlers", () => {
// The Options section is expanded on open, so the stdio fields are visible
// immediately when isStdio is true.
it("does not render the stdio fields when isStdio is false", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(
screen.queryByLabelText(/Working Directory/),
).not.toBeInTheDocument();
});
it("calls onSettingsChange when adding an environment variable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Environment Variables section is collapsed on open — expand it.
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.click(
screen.getByRole("button", { name: "+ Add Environment Variable" }),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
env: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing an environment variable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.click(
screen.getByRole("button", {
name: "Remove environment variable, A, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
env: [],
});
});
it("calls onSettingsChange with the updated env value when typing", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.type(screen.getByDisplayValue("1"), "2");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.env[0]).toEqual({ key: "A", value: "12" });
});
it("preserves sibling rows when editing one of several env vars", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{
...emptySettings,
env: [
{ key: "A", value: "1" },
{ key: "B", value: "2" },
],
}}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.type(screen.getByDisplayValue("1"), "0");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
// The untouched second env row survives the edit (the non-matching map
// branch).
expect(lastCall.env[1]).toEqual({ key: "B", value: "2" });
});
it("calls onSettingsChange when editing the working directory", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, cwd: "/srv" }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// Controlled input: the value prop stays "/srv" (the parent vi.fn does not
// feed edits back), so a single keystroke appends to that base.
await user.type(screen.getByLabelText(/Working Directory/), "X");
expect(onSettingsChange).toHaveBeenLastCalledWith({
...emptySettings,
cwd: "/srvX",
});
});
});
describe("secret-storage footer (#1950)", () => {
// The dialog holds the OAuth client secret and every stdio `env:` value, so
// the destination of what is typed here is stated permanently rather than
// in a toast that has long since gone.
it("names the active store", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
secretStorage={{
kind: "memory",
reason: "fallback",
durable: false,
}}
/>,
);
// Full-text assertion: the "Secrets:" prefix is its own `<span>`, which
// `getByText`'s direct-text-node matching would not see as one string.
expect(screen.getByTestId("secret-storage-footer")).toHaveTextContent(
"Secrets: Memory (this session only)",
);
});
it("renders no footer when the backend didn't report a store", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByTestId("secret-storage-footer")).toBeNull();
});
});
});