* 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
7 changed files with 716 additions and 6 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue