feat(web): add per-server Roots config + client roots capability (#1425) (#1431)

* 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:
Cliff Hall 2026-06-05 11:08:18 -04:00 • committed by GitHub
commit 49d6be6987
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 716 additions and 6 deletions

View file

@ -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" },
],
},
},
};

View file

@ -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();

View file

@ -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>