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