* feat(web): add per-server Roots config + client roots capability (#1425) Add MCP roots support to the v2 web Inspector as a per-server config section in the server settings form (no separate Roots tab/screen). - Persistence: add `roots: Root[]` to `InspectorServerSettings` and `StoredMCPServer`; thread through serverList converters (lift on read, drop empty-uri rows / blank names on write, omit when empty) and the remote server's read-side drop guard + `validateSettings`. - Capability: `setupClientForServer` always advertises the roots capability (`roots: cleanRoots(savedSettings?.roots ?? [])`) so the server can issue `roots/list` and receive `roots/list_changed`. - Live apply: edits to a connected server's roots are pushed via `inspectorClient.setRoots(...)` once, on settings-dialog close — diffed against the client's current roots — rather than per keystroke, to avoid flooding the server with `roots/list_changed` / `roots/list`. - Form: new Roots accordion section with a required URI and an optional name per row (unlike v1, which was URI-only), + Add Root, and remove. Tests cover the persistence round-trip (serverList + servers-route), the form/modal section, and reuse the existing InspectorClient roots/`setRoots` integration coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(roots): address review — dedup guards/cleanRoots, preserve extra fields, test live-apply Responding to the PR #1431 code review: - Dedup `isRootArray`: drop the copy inside `validateSettings` and reuse the module-scope guard the read path uses, so the two can't drift (core/mcp/remote/node/server.ts). - Dedup the clean-roots normalizer: hoist a single exported `cleanRoots` into core/mcp/serverList.ts and reuse it from both `inspectorSettingsToStoredFields` and App.tsx (drops the local copy). - Preserve non-form root fields (e.g. `_meta`): `cleanRoots` now spreads the carried-through fields and only normalizes uri/name, and the modal's `handleRootChange` spreads the existing root on edit — so a hand-edited root's `_meta` survives load + persist + edit instead of being silently dropped. - Test the App live-apply-on-close path: new App.test.tsx cases assert setRoots is called once with cleaned roots when changed on the active server, and not called when the cleaned roots already match what the client advertises. Adds a cleanRoots unit test (blank drop + _meta preserve). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ed6eda7492
commit
49d6be6987
18 changed files with 716 additions and 6 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
renderWithMantine,
|
||||
screen,
|
||||
|
|
@ -53,6 +53,8 @@ vi.mock("@inspector/core/mcp/index.js", () => {
|
|||
getOAuthState = vi.fn().mockReturnValue(undefined);
|
||||
getPendingSamples = vi.fn().mockReturnValue([]);
|
||||
getPendingElicitations = vi.fn().mockReturnValue([]);
|
||||
getRoots = vi.fn().mockReturnValue([]);
|
||||
setRoots = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
const instances: FakeInspectorClient[] = [];
|
||||
return {
|
||||
|
|
@ -187,7 +189,9 @@ vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({
|
|||
}));
|
||||
vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({
|
||||
useSettingsDraft: vi.fn(() => ({
|
||||
draft: undefined,
|
||||
// `draft` is widened so tests can override the return with a populated
|
||||
// settings draft via `mockReturnValue` (the roots live-apply-on-close path).
|
||||
draft: undefined as InspectorServerSettings | undefined,
|
||||
onChange: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
})),
|
||||
|
|
@ -243,6 +247,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
onCancelTask: (taskId: string) => void;
|
||||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="tool-status">
|
||||
|
|
@ -339,6 +344,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
read-resource
|
||||
</button>
|
||||
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
||||
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
|
@ -347,6 +353,8 @@ import App from "./App";
|
|||
import * as McpIndex from "@inspector/core/mcp/index.js";
|
||||
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
|
||||
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
|
||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
|
||||
// Default useInspectorClient return — capabilities empty (no task tool calls).
|
||||
// Individual tests override via vi.mocked(...).mockReturnValue(...).
|
||||
|
|
@ -843,3 +851,102 @@ describe("App task wiring", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Live-apply roots on settings-dialog close: the App diffs the final draft
|
||||
// roots against what the connected client advertises and calls `setRoots`
|
||||
// once (not per keystroke), and only for the active server. App.tsx is
|
||||
// excluded from the coverage gate, but the acceptance criterion ("editing
|
||||
// roots on a live connection notifies the server on dialog close") lives
|
||||
// here, so it's worth a direct test of the gate/diff/notify path.
|
||||
type RootsFakeClient = EventTarget & {
|
||||
setRoots: ReturnType<typeof vi.fn>;
|
||||
getRoots: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const settingsWithRoots = (
|
||||
roots: InspectorServerSettings["roots"],
|
||||
): InspectorServerSettings => ({
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
roots,
|
||||
});
|
||||
|
||||
describe("App roots live-apply on settings-dialog close", () => {
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the default empty draft so the override doesn't leak.
|
||||
vi.mocked(useSettingsDraft).mockReturnValue({
|
||||
draft: undefined,
|
||||
onChange: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
async function openSettingsForConnectedServer(
|
||||
draft: InspectorServerSettings,
|
||||
): Promise<RootsFakeClient> {
|
||||
vi.mocked(useSettingsDraft).mockReturnValue({
|
||||
draft,
|
||||
onChange: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
await user.click(screen.getByText("open-settings"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Server Settings")).toBeInTheDocument(),
|
||||
);
|
||||
return clientInstances[0] as RootsFakeClient;
|
||||
}
|
||||
|
||||
async function closeModal(user: ReturnType<typeof userEvent.setup>) {
|
||||
const closeBtn = document.querySelector(
|
||||
"button.mantine-CloseButton-root",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(closeBtn).not.toBeNull();
|
||||
await user.click(closeBtn!);
|
||||
}
|
||||
|
||||
it("calls setRoots once with cleaned roots when roots changed on the active server", async () => {
|
||||
const user = userEvent.setup();
|
||||
const client = await openSettingsForConnectedServer(
|
||||
// A blank-uri row (left mid-edit) must be dropped; the named root kept.
|
||||
settingsWithRoots([{ uri: "file:///x", name: "X" }, { uri: "" }]),
|
||||
);
|
||||
// Client currently advertises no roots → the draft differs → notify.
|
||||
client.getRoots.mockReturnValue([]);
|
||||
|
||||
await closeModal(user);
|
||||
|
||||
await waitFor(() => expect(client.setRoots).toHaveBeenCalledTimes(1));
|
||||
expect(client.setRoots).toHaveBeenCalledWith([
|
||||
{ uri: "file:///x", name: "X" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not call setRoots when the cleaned roots match what the client advertises", async () => {
|
||||
const user = userEvent.setup();
|
||||
const client = await openSettingsForConnectedServer(
|
||||
settingsWithRoots([{ uri: "file:///x" }]),
|
||||
);
|
||||
// Same roots already advertised → no notification on close.
|
||||
client.getRoots.mockReturnValue([{ uri: "file:///x" }]);
|
||||
|
||||
await closeModal(user);
|
||||
|
||||
// Let any close-handler microtasks settle, then assert no notification.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Server Settings")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(client.setRoots).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,7 +42,10 @@ import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResource
|
|||
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
|
||||
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
|
||||
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
||||
import { serializeMcpConfig } from "@inspector/core/mcp/serverList.js";
|
||||
import {
|
||||
cleanRoots,
|
||||
serializeMcpConfig,
|
||||
} from "@inspector/core/mcp/serverList.js";
|
||||
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
|
||||
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
||||
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
|
||||
|
|
@ -197,6 +200,7 @@ const EMPTY_SETTINGS: InspectorServerSettings = {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
roots: [],
|
||||
};
|
||||
|
||||
// Body of the output-schema-mismatch warning toast: a one-line summary plus a
|
||||
|
|
@ -951,6 +955,11 @@ function App() {
|
|||
// Sampling / elicitation are on by default; keep the parameterized
|
||||
// options off until the UI grows the surface to render them.
|
||||
elicit: { form: true, url: true },
|
||||
// Always advertise the roots capability (even with no configured
|
||||
// roots) so the server can issue roots/list and receive
|
||||
// roots/list_changed; the configured roots are the answer to
|
||||
// roots/list. Empty-uri rows are dropped before they reach the wire.
|
||||
roots: cleanRoots(savedSettings?.roots ?? []),
|
||||
...(savedSettings &&
|
||||
savedSettings.requestTimeout > 0 && {
|
||||
timeout: savedSettings.requestTimeout,
|
||||
|
|
@ -1693,8 +1702,36 @@ function App() {
|
|||
|
||||
const onSettingsModalClose = useCallback(() => {
|
||||
flushSettingsDraft();
|
||||
// Apply root edits to the live client once, on close — not on every
|
||||
// keystroke. `setRoots` fires `notifications/roots/list_changed`, which
|
||||
// makes the server re-request `roots/list`; doing that per character while
|
||||
// the user types a URI would flood the wire. We diff the final draft roots
|
||||
// against what the client currently advertises (both cleaned) and notify
|
||||
// only when they actually differ, and only for the connected server.
|
||||
if (
|
||||
inspectorClient &&
|
||||
settingsModalTargetId !== undefined &&
|
||||
settingsModalTargetId === activeServerId &&
|
||||
settingsDraft
|
||||
) {
|
||||
const nextRoots = cleanRoots(settingsDraft.roots);
|
||||
const currentRoots = cleanRoots(inspectorClient.getRoots());
|
||||
if (JSON.stringify(nextRoots) !== JSON.stringify(currentRoots)) {
|
||||
void inspectorClient.setRoots(nextRoots).catch(() => {
|
||||
// setRoots swallows notification failures internally; a throw here
|
||||
// only means the client is mid-teardown — the persisted roots will
|
||||
// re-advertise on the next connect, so nothing to surface.
|
||||
});
|
||||
}
|
||||
}
|
||||
setSettingsModalTargetId(undefined);
|
||||
}, [flushSettingsDraft]);
|
||||
}, [
|
||||
flushSettingsDraft,
|
||||
inspectorClient,
|
||||
settingsModalTargetId,
|
||||
activeServerId,
|
||||
settingsDraft,
|
||||
]);
|
||||
|
||||
// The Resources screen needs `isSubscribed` to flip the Subscribe button
|
||||
// label to "Unsubscribe". Derive it from the live subscriptions list rather
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const defaultSettings: InspectorServerSettings = {
|
|||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
roots: [],
|
||||
};
|
||||
|
||||
function InteractiveRender(args: ServerSettingsFormProps) {
|
||||
|
|
@ -171,6 +172,10 @@ export const AllConfigured: Story = {
|
|||
oauthClientId: "my-client-id",
|
||||
oauthClientSecret: "super-secret-value",
|
||||
oauthScopes: "read write",
|
||||
roots: [
|
||||
{ uri: "file:///home/user/project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const emptySettings: InspectorServerSettings = {
|
|||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
roots: [],
|
||||
};
|
||||
|
||||
const populatedSettings: InspectorServerSettings = {
|
||||
|
|
@ -24,6 +25,7 @@ const populatedSettings: InspectorServerSettings = {
|
|||
oauthClientId: "cid",
|
||||
oauthClientSecret: "secret",
|
||||
oauthScopes: "read",
|
||||
roots: [{ uri: "file:///project", name: "Project" }],
|
||||
};
|
||||
|
||||
const allSections: ServerSettingsSection[] = [
|
||||
|
|
@ -31,6 +33,7 @@ const allSections: ServerSettingsSection[] = [
|
|||
"metadata",
|
||||
"timeouts",
|
||||
"oauth",
|
||||
"roots",
|
||||
];
|
||||
|
||||
const baseHandlers = {
|
||||
|
|
@ -43,6 +46,9 @@ const baseHandlers = {
|
|||
onMetadataChange: vi.fn(),
|
||||
onTimeoutChange: vi.fn(),
|
||||
onOAuthChange: vi.fn(),
|
||||
onAddRoot: vi.fn(),
|
||||
onRemoveRoot: vi.fn(),
|
||||
onRootChange: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ServerSettingsForm", () => {
|
||||
|
|
@ -58,6 +64,7 @@ describe("ServerSettingsForm", () => {
|
|||
expect(screen.getByText("Request Metadata")).toBeInTheDocument();
|
||||
expect(screen.getByText("Timeouts")).toBeInTheDocument();
|
||||
expect(screen.getByText("OAuth Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Roots")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty hints for headers and metadata when no entries exist", () => {
|
||||
|
|
@ -288,6 +295,71 @@ describe("ServerSettingsForm", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows the empty hint for roots when none are configured", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={emptySettings}
|
||||
expandedSections={["roots"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No roots configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onAddRoot when + Add Root is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddRoot = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onAddRoot={onAddRoot}
|
||||
settings={emptySettings}
|
||||
expandedSections={["roots"]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "+ Add Root" }));
|
||||
expect(onAddRoot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a row's uri and optional name and reports edits via onRootChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRootChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onRootChange={onRootChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["roots"]}
|
||||
/>,
|
||||
);
|
||||
// populatedSettings has one root { uri: "file:///project", name: "Project" }
|
||||
expect(screen.getByDisplayValue("file:///project")).toBeInTheDocument();
|
||||
const nameInput = screen.getByDisplayValue("Project");
|
||||
await user.type(nameInput, "X");
|
||||
expect(onRootChange).toHaveBeenCalled();
|
||||
const lastCall =
|
||||
onRootChange.mock.calls[onRootChange.mock.calls.length - 1];
|
||||
expect(lastCall[0]).toBe(0);
|
||||
// uri is threaded through unchanged when only the name changes
|
||||
expect(lastCall[1]).toBe("file:///project");
|
||||
});
|
||||
|
||||
it("invokes onRemoveRoot when X is clicked on a root row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemoveRoot = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onRemoveRoot={onRemoveRoot}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["roots"]}
|
||||
/>,
|
||||
);
|
||||
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(onRemoveRoot).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("invokes onExpandedSectionsChange when an Accordion section is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExpandedSectionsChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import type {
|
|||
InspectorServerSettings,
|
||||
OAuthSettings,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import type { Root } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export type ServerSettingsSection =
|
||||
| "headers"
|
||||
| "metadata"
|
||||
| "timeouts"
|
||||
| "oauth";
|
||||
| "oauth"
|
||||
| "roots";
|
||||
|
||||
export interface ServerSettingsFormProps {
|
||||
settings: InspectorServerSettings;
|
||||
|
|
@ -34,6 +36,9 @@ export interface ServerSettingsFormProps {
|
|||
value: number,
|
||||
) => void;
|
||||
onOAuthChange: (oauth: OAuthSettings) => void;
|
||||
onAddRoot: () => void;
|
||||
onRemoveRoot: (index: number) => void;
|
||||
onRootChange: (index: number, uri: string, name: string) => void;
|
||||
}
|
||||
|
||||
const RemoveIcon = ActionIcon.withProps({
|
||||
|
|
@ -91,6 +96,42 @@ function KeyValueRows({
|
|||
);
|
||||
}
|
||||
|
||||
function RootRows({
|
||||
roots,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
roots: Root[];
|
||||
onChange: (index: number, uri: string, name: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
if (roots.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{roots.map((root, index) => (
|
||||
<Group key={index} grow>
|
||||
<TextInput
|
||||
placeholder="URI (e.g. file:///path)"
|
||||
value={root.uri}
|
||||
onChange={(e) =>
|
||||
onChange(index, e.currentTarget.value, root.name ?? "")
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Name (optional)"
|
||||
value={root.name ?? ""}
|
||||
onChange={(e) => onChange(index, root.uri, e.currentTarget.value)}
|
||||
/>
|
||||
<RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerSettingsForm({
|
||||
settings,
|
||||
expandedSections,
|
||||
|
|
@ -103,6 +144,9 @@ export function ServerSettingsForm({
|
|||
onMetadataChange,
|
||||
onTimeoutChange,
|
||||
onOAuthChange,
|
||||
onAddRoot,
|
||||
onRemoveRoot,
|
||||
onRootChange,
|
||||
}: ServerSettingsFormProps) {
|
||||
const handleTimeoutChange =
|
||||
(field: "connectionTimeout" | "requestTimeout" | "taskTtl") =>
|
||||
|
|
@ -204,6 +248,30 @@ export function ServerSettingsForm({
|
|||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="roots">
|
||||
<Accordion.Control>Roots</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<HintText>
|
||||
Configure the root directories that the server can access. Each
|
||||
root needs a URI; the name is optional.
|
||||
</HintText>
|
||||
<AddButton onClick={onAddRoot}>+ Add Root</AddButton>
|
||||
</Group>
|
||||
{settings.roots.length === 0 ? (
|
||||
<EmptyHint>No roots configured</EmptyHint>
|
||||
) : (
|
||||
<RootRows
|
||||
roots={settings.roots}
|
||||
onChange={onRootChange}
|
||||
onRemove={onRemoveRoot}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="oauth">
|
||||
<Accordion.Control>OAuth Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ const initialSettings: InspectorServerSettings = {
|
|||
oauthClientId: "my-client-id",
|
||||
oauthClientSecret: "super-secret-value",
|
||||
oauthScopes: "read write",
|
||||
roots: [
|
||||
{ uri: "file:///home/user/project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
],
|
||||
};
|
||||
|
||||
function InteractiveRender(args: ServerSettingsModalProps) {
|
||||
|
|
@ -69,6 +73,7 @@ export const EmptySettings: Story = {
|
|||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
roots: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const initialSettings: InspectorServerSettings = {
|
|||
oauthClientId: "cid",
|
||||
oauthClientSecret: "secret",
|
||||
oauthScopes: "read",
|
||||
roots: [{ uri: "file:///project", name: "Project" }],
|
||||
};
|
||||
|
||||
const emptySettings: InspectorServerSettings = {
|
||||
|
|
@ -21,6 +22,7 @@ const emptySettings: InspectorServerSettings = {
|
|||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
roots: [],
|
||||
};
|
||||
|
||||
describe("ServerSettingsModal", () => {
|
||||
|
|
@ -225,6 +227,67 @@ describe("ServerSettingsModal", () => {
|
|||
expect(call.oauthClientId).toBe("a");
|
||||
});
|
||||
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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(
|
||||
|
|
@ -243,6 +306,7 @@ describe("ServerSettingsModal", () => {
|
|||
});
|
||||
const timeoutsControl = screen.getByRole("button", { name: "Timeouts" });
|
||||
const oauthControl = screen.getByRole("button", { name: "OAuth Settings" });
|
||||
const rootsControl = screen.getByRole("button", { name: "Roots" });
|
||||
|
||||
// Initially only "headers" is expanded.
|
||||
expect(headersControl.getAttribute("aria-expanded")).toBe("true");
|
||||
|
|
@ -255,6 +319,7 @@ describe("ServerSettingsModal", () => {
|
|||
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" }));
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const ALL_SECTIONS: ServerSettingsSection[] = [
|
|||
"metadata",
|
||||
"timeouts",
|
||||
"oauth",
|
||||
"roots",
|
||||
];
|
||||
|
||||
export interface ServerSettingsModalProps {
|
||||
|
|
@ -98,6 +99,29 @@ export function ServerSettingsModal({
|
|||
});
|
||||
}
|
||||
|
||||
function handleAddRoot() {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
roots: [...settings.roots, { uri: "", name: "" }],
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemoveRoot(index: number) {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
roots: settings.roots.filter((_, i) => i !== index),
|
||||
});
|
||||
}
|
||||
|
||||
function handleRootChange(index: number, uri: string, name: string) {
|
||||
// Spread the existing root so any non-form fields (e.g. `_meta` from a
|
||||
// hand-edited mcp.json) survive an edit; only `uri`/`name` are overwritten.
|
||||
const roots = settings.roots.map((r, i) =>
|
||||
i === index ? { ...r, uri, name } : r,
|
||||
);
|
||||
onSettingsChange({ ...settings, roots });
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
|
|
@ -130,6 +154,9 @@ export function ServerSettingsModal({
|
|||
onMetadataChange={handleMetadataChange}
|
||||
onTimeoutChange={handleTimeoutChange}
|
||||
onOAuthChange={handleOAuthChange}
|
||||
onAddRoot={handleAddRoot}
|
||||
onRemoveRoot={handleRemoveRoot}
|
||||
onRootChange={handleRootChange}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ describe("RemoteClientTransport", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
};
|
||||
const transport = new RemoteClientTransport(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
cleanRoots,
|
||||
DEFAULT_SEED_CONFIG,
|
||||
expectedSecretFields,
|
||||
extractSecretsFromStored,
|
||||
|
|
@ -74,6 +75,31 @@ describe("normalizeServerType", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("cleanRoots", () => {
|
||||
it("drops blank-uri rows and trims/drops blank names", () => {
|
||||
expect(
|
||||
cleanRoots([
|
||||
{ uri: "file:///a", name: " Alpha " },
|
||||
{ uri: "file:///b", name: " " },
|
||||
{ uri: " " },
|
||||
{ uri: "" },
|
||||
]),
|
||||
).toEqual([{ uri: "file:///a", name: "Alpha" }, { uri: "file:///b" }]);
|
||||
});
|
||||
|
||||
it("preserves non-form fields (e.g. _meta) on surviving rows", () => {
|
||||
expect(
|
||||
cleanRoots([
|
||||
{ uri: "file:///a", name: "Alpha", _meta: { k: 1 } },
|
||||
{ uri: "file:///b", _meta: { k: 2 } },
|
||||
]),
|
||||
).toEqual([
|
||||
{ uri: "file:///a", name: "Alpha", _meta: { k: 1 } },
|
||||
{ uri: "file:///b", _meta: { k: 2 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpConfigToServerEntries", () => {
|
||||
it("uses the map key as both id and name", () => {
|
||||
const cfg: MCPConfig = {
|
||||
|
|
@ -203,6 +229,8 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
requestTimeout: 0,
|
||||
// Absent taskTtl on disk → product default in memory (for the form)
|
||||
taskTtl: 60000,
|
||||
// Absent roots on disk → empty list in memory (for the form)
|
||||
roots: [],
|
||||
// Nested oauth on disk → flat oauthClientId in memory
|
||||
oauthClientId: "the-client",
|
||||
});
|
||||
|
|
@ -275,6 +303,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
|
|
@ -299,6 +328,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
|
|
@ -310,6 +340,90 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
expect(stored).not.toHaveProperty("oauth");
|
||||
expect(stored).not.toHaveProperty("headers");
|
||||
expect(stored).not.toHaveProperty("metadata");
|
||||
expect(stored).not.toHaveProperty("roots");
|
||||
});
|
||||
|
||||
it("round-trips roots (uri + optional name) onto the top-level disk field", () => {
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha",
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [
|
||||
{ uri: "file:///project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
],
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
];
|
||||
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||
expect(stored?.roots).toEqual([
|
||||
{ uri: "file:///project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
]);
|
||||
// Disk → memory lifts the same roots back onto settings.
|
||||
const [entry] = mcpConfigToServerEntries({
|
||||
mcpServers: { alpha: stored! },
|
||||
});
|
||||
expect(entry?.settings?.roots).toEqual([
|
||||
{ uri: "file:///project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops empty-uri root rows and blank names on serialize", () => {
|
||||
// The form leaves a blank row when the user clicks 'Add Root' but hasn't
|
||||
// typed a URI yet; a cleared optional name should not write `name: ""`.
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha",
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [
|
||||
{ uri: "file:///keep", name: " " },
|
||||
{ uri: " " },
|
||||
{ uri: "" },
|
||||
],
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
];
|
||||
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||
expect(stored?.roots).toEqual([{ uri: "file:///keep" }]);
|
||||
});
|
||||
|
||||
it("omits the roots field entirely when no rows survive filtering", () => {
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha",
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [{ uri: "" }],
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
];
|
||||
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||
expect(stored).not.toHaveProperty("roots");
|
||||
});
|
||||
|
||||
it("preserves insertion order on serialize", () => {
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ describe("useServers", () => {
|
|||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
taskTtl: 30000,
|
||||
roots: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -402,6 +403,7 @@ describe("useServers", () => {
|
|||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
taskTtl: 30000,
|
||||
roots: [],
|
||||
});
|
||||
});
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
|
|
@ -433,6 +435,7 @@ describe("useServers", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
|
|
@ -481,6 +484,8 @@ describe("useServers", () => {
|
|||
requestTimeout: 0,
|
||||
// Absent taskTtl on disk reads back as the product default for the form.
|
||||
taskTtl: 60000,
|
||||
// Absent roots on disk reads back as an empty list for the form.
|
||||
roots: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ describe("InspectorClient", () => {
|
|||
connectionTimeout: 50,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ describe("Transport", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
};
|
||||
|
||||
const fetchRequests: FetchRequestEntryBase[] = [];
|
||||
|
|
@ -269,6 +270,7 @@ describe("Transport", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
};
|
||||
|
||||
const fetchRequests: FetchRequestEntryBase[] = [];
|
||||
|
|
@ -311,6 +313,7 @@ describe("Transport", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
};
|
||||
const result = createTransportNode(config, { settings });
|
||||
// Just exercise the empty-headers path — no transport construction
|
||||
|
|
|
|||
|
|
@ -829,6 +829,8 @@ describe("/api/servers routes", () => {
|
|||
requestTimeout: 60000,
|
||||
// Bad: array instead of object → drop
|
||||
oauth: ["not", "an", "object"],
|
||||
// Bad: entry missing a string `uri` → drop the whole roots field
|
||||
roots: [{ name: "no-uri" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -847,6 +849,114 @@ describe("/api/servers routes", () => {
|
|||
expect(fetched).not.toHaveProperty("metadata");
|
||||
expect(fetched).not.toHaveProperty("connectionTimeout");
|
||||
expect(fetched).not.toHaveProperty("oauth");
|
||||
expect(fetched).not.toHaveProperty("roots");
|
||||
});
|
||||
|
||||
it("round-trips roots (uri + optional name) on PUT and drops empty-uri rows", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
"roots-srv": { type: "stdio", command: "node" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/roots-srv`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
roots: [
|
||||
{ uri: "file:///project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
// Empty-uri row left mid-edit by the form → dropped on write.
|
||||
{ uri: "" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers[
|
||||
"roots-srv"
|
||||
] as unknown as Record<string, unknown>;
|
||||
expect(stored.roots).toEqual([
|
||||
{ uri: "file:///project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
]);
|
||||
|
||||
// The GET round-trip surfaces the same roots back to the form.
|
||||
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
||||
const getBody = (await getRes.json()) as {
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
expect(getBody.mcpServers["roots-srv"]!.roots).toEqual([
|
||||
{ uri: "file:///project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the roots field on disk when all rows are empty", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
"roots-empty": { type: "stdio", command: "node" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/roots-empty`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
roots: [{ uri: " " }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers[
|
||||
"roots-empty"
|
||||
] as unknown as Record<string, unknown>;
|
||||
expect(stored).not.toHaveProperty("roots");
|
||||
});
|
||||
|
||||
it("rejects a malformed roots shape with 400", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
"roots-bad": { type: "stdio", command: "node" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/roots-bad`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
// uri must be a string.
|
||||
roots: [{ uri: 42 }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toMatch(/settings\.roots/);
|
||||
});
|
||||
|
||||
it("loads a hand-edited file with top-level Claude Code-style `headers` (interop with `.mcp.json`)", async () => {
|
||||
|
|
|
|||
|
|
@ -411,6 +411,7 @@ describe("Remote transport e2e", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
roots: [],
|
||||
};
|
||||
|
||||
const createTransport = createRemoteTransport({ baseUrl, authToken });
|
||||
|
|
|
|||
|
|
@ -870,6 +870,21 @@ export function createRemoteApp(
|
|||
}
|
||||
return true;
|
||||
};
|
||||
// A roots array: each entry must have a string `uri` and, when present, a
|
||||
// string `name` (SDK `Root`). Other keys (e.g. `_meta`) pass through — we
|
||||
// only gate the two fields the Inspector reads/writes.
|
||||
const isRootArray = (
|
||||
v: unknown,
|
||||
): v is { uri: string; name?: string }[] => {
|
||||
if (!Array.isArray(v)) return false;
|
||||
return v.every((e) => {
|
||||
if (e === null || typeof e !== "object") return false;
|
||||
const o = e as Record<string, unknown>;
|
||||
if (typeof o.uri !== "string") return false;
|
||||
if (o.name !== undefined && typeof o.name !== "string") return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
// `Number.isFinite` rejects `Infinity` and `NaN` as well as non-numbers,
|
||||
// matching the write-side semantics in `validateSettings`. A hand-edited
|
||||
// file with `connectionTimeout: Infinity` would otherwise pass the guard
|
||||
|
|
@ -954,6 +969,13 @@ export function createRemoteApp(
|
|||
);
|
||||
delete valObj.oauth;
|
||||
}
|
||||
if ("roots" in valObj && !isRootArray(valObj.roots)) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "roots" },
|
||||
"Dropping malformed `roots` field — expected `Array<{ uri: string, name?: string }>`.",
|
||||
);
|
||||
delete valObj.roots;
|
||||
}
|
||||
|
||||
out[id] = normalizeServerType(
|
||||
valObj as Record<string, unknown> & { type?: string },
|
||||
|
|
@ -1096,6 +1118,16 @@ export function createRemoteApp(
|
|||
return { ok: false, error: `settings.${optional} must be a string` };
|
||||
}
|
||||
}
|
||||
// roots is optional on the wire (older clients won't send it); when
|
||||
// present it must be an array of `{ uri, name? }`. Reuses the same
|
||||
// module-scope `isRootArray` guard the read path applies in
|
||||
// `normalizeMcpServers`, so the two paths can't drift.
|
||||
if (obj.roots !== undefined && !isRootArray(obj.roots)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.roots must be an array of { uri, name? }",
|
||||
};
|
||||
}
|
||||
// Build the validated value from explicitly named fields rather than
|
||||
// casting the raw object through. Unknown keys silently drop so a
|
||||
// misconfigured client can't smuggle stowaways onto disk, and consumers
|
||||
|
|
@ -1114,6 +1146,11 @@ export function createRemoteApp(
|
|||
// to disk for a client that didn't send one.
|
||||
taskTtl:
|
||||
typeof obj.taskTtl === "number" ? obj.taskTtl : DEFAULT_TASK_TTL_MS,
|
||||
// Absent → empty list, matching the read side
|
||||
// (storedFieldsToInspectorSettings). Empty rows are dropped on the way
|
||||
// to disk by inspectorSettingsToStoredFields, so an empty array here
|
||||
// writes no spurious `roots` field.
|
||||
roots: isRootArray(obj.roots) ? obj.roots : [],
|
||||
};
|
||||
if (typeof obj.oauthClientId === "string" && obj.oauthClientId !== "") {
|
||||
value.oauthClientId = obj.oauthClientId;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { DEFAULT_TASK_TTL_MS } from "./types.js";
|
||||
import type { Root } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPConfig,
|
||||
|
|
@ -57,6 +58,27 @@ export function normalizeServerType(
|
|||
return { ...config, type: normalizedType } as MCPServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the form's controlled root rows into the shape the Inspector
|
||||
* advertises and persists: drop rows whose `uri` is blank (the form leaves a
|
||||
* new row empty mid-edit) and drop a blank/whitespace `name`. Any other fields
|
||||
* a root carries (e.g. `_meta` from a hand-edited `mcp.json`) are preserved —
|
||||
* only `uri`/`name` are normalized. Shared by the settings → disk converter
|
||||
* (`inspectorSettingsToStoredFields`) and the web client's connect-time +
|
||||
* `setRoots` wiring so the roots told to the server match what hits disk.
|
||||
*/
|
||||
export function cleanRoots(roots: Root[]): Root[] {
|
||||
return roots
|
||||
.filter((r) => r.uri.trim() !== "")
|
||||
.map((r) => {
|
||||
const trimmedName = r.name?.trim();
|
||||
// Strip `name` off the carried-through rest so a cleared optional name
|
||||
// doesn't persist as `name: ""`; re-add it only when non-empty.
|
||||
const { name: _name, ...rest } = r;
|
||||
return trimmedName ? { ...rest, name: trimmedName } : rest;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Inspector-extension fields that live as direct keys on a `StoredMCPServer`
|
||||
* (post-#1358) — split out from `MCPServerConfig` so both directions of the
|
||||
|
|
@ -71,6 +93,7 @@ type StoredInspectorFields = Pick<
|
|||
| "requestTimeout"
|
||||
| "taskTtl"
|
||||
| "oauth"
|
||||
| "roots"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
|
@ -94,7 +117,8 @@ export function storedFieldsToInspectorSettings(
|
|||
stored.connectionTimeout !== undefined ||
|
||||
stored.requestTimeout !== undefined ||
|
||||
stored.taskTtl !== undefined ||
|
||||
stored.oauth !== undefined;
|
||||
stored.oauth !== undefined ||
|
||||
stored.roots !== undefined;
|
||||
if (!hasAny) return undefined;
|
||||
|
||||
const headersPairs: { key: string; value: string }[] = stored.headers
|
||||
|
|
@ -109,6 +133,10 @@ export function storedFieldsToInspectorSettings(
|
|||
// Unlike the timeouts (0 = "SDK default"), task TTL has a concrete product
|
||||
// default so the form shows it and "Run as task" has a value to send.
|
||||
taskTtl: stored.taskTtl ?? DEFAULT_TASK_TTL_MS,
|
||||
// Defaults to an empty list so the form always has a concrete array to
|
||||
// render controlled rows from. An absent on-disk `roots` reads back as
|
||||
// `[]`, which `inspectorSettingsToStoredFields` then omits on write.
|
||||
roots: stored.roots ?? [],
|
||||
};
|
||||
// Truthiness drops empty-string OAuth fields — mirrors the write-side
|
||||
// coercion in `validateSettings` (server.ts) so a round-trip can't
|
||||
|
|
@ -179,6 +207,14 @@ export function inspectorSettingsToStoredFields(
|
|||
out.oauth = oauthFields;
|
||||
}
|
||||
|
||||
// Drop empty-uri rows / blank names via the shared normalizer; omit the
|
||||
// field entirely when nothing survives, keeping the diff minimal for entries
|
||||
// that never configured roots.
|
||||
const rootsFiltered = cleanRoots(settings.roots);
|
||||
if (rootsFiltered.length > 0) {
|
||||
out.roots = rootsFiltered;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +239,7 @@ const INSPECTOR_FIELD_KEY_MAP = {
|
|||
requestTimeout: true,
|
||||
taskTtl: true,
|
||||
oauth: true,
|
||||
roots: true,
|
||||
} as const satisfies Record<keyof StoredInspectorFields, true>;
|
||||
|
||||
export const INSPECTOR_FIELD_KEYS = new Set(
|
||||
|
|
|
|||
|
|
@ -115,6 +115,14 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
};
|
||||
/**
|
||||
* Filesystem/URI roots advertised to the server via the `roots` client
|
||||
* capability. Inspector-specific (no analog in the broader mcp.json
|
||||
* ecosystem). Each root is the SDK `Root` shape `{ uri, name? }`; unlike v1
|
||||
* (URI-only), the optional `name` round-trips here. Persisted as-is on disk
|
||||
* and lifted onto `InspectorServerSettings.roots` when read into memory.
|
||||
*/
|
||||
roots?: Root[];
|
||||
};
|
||||
|
||||
export interface MCPConfig {
|
||||
|
|
@ -364,6 +372,13 @@ export interface InspectorServerSettings {
|
|||
oauthClientId?: string;
|
||||
oauthClientSecret?: string;
|
||||
oauthScopes?: string;
|
||||
/**
|
||||
* Roots advertised to the server via the `roots` client capability. Each
|
||||
* root carries a required `uri` and an optional `name` (SDK `Root`). The
|
||||
* form edits these as controlled rows; empty-uri rows are dropped on
|
||||
* persist (see `inspectorSettingsToStoredFields`).
|
||||
*/
|
||||
roots: Root[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue