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>
873 lines
29 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|