* feat(servers): persist per-server settings to mcp.json (#1352) - Add optional `settings` node alongside the transport config in each `~/.mcp-inspector/mcp.json` entry. Round-trips through `mcpConfigToServerEntries` / `serverEntriesToMcpConfig` and the `/api/servers` POST/PUT routes. - Drop `MCPServerConfig.headers` from `SseServerConfig` / `StreamableHttpServerConfig` and remove the headers textarea from `ServerConfigModal`. Custom headers are now entered only in `ServerSettingsForm` and reach the wire via `settings.headers`. - Wire `ServerSettingsModal` into `App.tsx`; saves dispatch through a new `useServers.updateServerSettings` mutator. `updateServer` carries the existing settings across config-only saves so they aren't dropped. - Apply settings on the *first* outbound request after a server loads — no need to open the settings form first: - `settings.headers` → SSE / streamable-http wire headers. - `settings.metadata` → `InspectorClientOptions.defaultMetadata`, merged into every outgoing `_meta` (call-time keys win). - `settings.requestTimeout` → `InspectorClientOptions.timeout`. - `settings.connectionTimeout` → Promise.race wrapper around connect(). - `settings.oauth*` → pre-seeded OAuth client credentials. - `RemoteConnectRequest` carries `settings` so the remote backend can source transport headers from the same source as the local path. - `oauthClientSecret` is persisted in `mcp.json` alongside stdio `env` values, both protected by the file's `0o600` permission. - Update `specification/v2_servers_file.md` with the new shape and the removal of the legacy headers field. Tests: round-trip in `serverList.test.ts`; persistence on POST/PUT in `servers-route.test.ts`; first-connect header injection in `transport.test.ts`; `defaultMetadata` merging in `inspectorClient.test.ts`; `useServers.updateServerSettings` + `updateServer` settings-preservation in `useServers.test.tsx`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): rebuild InspectorClient on every connect so settings refresh The previous onToggleConnection branch reused the existing InspectorClient on a same-server reconnect, which froze settings at the moment the client was first constructed. Editing headers, metadata, timeouts, or OAuth credentials in the settings form was therefore invisible until the user either switched servers or reloaded the page — surprising right after a visible save. Always call setupClientForServer on (re)connect so the latest target.settings flow through to the transport's headers / requestInit, to defaultMetadata, and to the OAuth manager. Also adds remoteClientTransport tests that pin the wire-level contract: settings round-trip through the /api/mcp/connect body when supplied, and the field is omitted when no settings are configured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(history): stop wiping pendingRequestEntries inside the connect event State managers (ManagedToolsState, ManagedPromptsState, ManagedResourcesState, ManagedResourceTemplatesState, ManagedRequestorTasksState) handle the client's "connect" event by calling `void this.refresh()`. The synchronous portion of refresh runs through SDK Protocol.request → transport.send → MessageTrackingTransport.send → trackRequest, all before yielding to the network — so each list request gets tracked into MessageLogState's pendingRequestEntries inside the same connect event dispatch. MessageLogState's own "connect" listener was registered after those state managers (it's the last one constructed in App.tsx) and cleared pendingRequestEntries on every connect. Listener order meant the clear ran *after* the requests were tracked but before their responses arrived, leaving the history with unmatched "response" entries marked PENDING. The clear-on-disconnect (via statusChange → disconnected) already handles session-boundary cleanup, so clear-on-connect was purely defensive and now actively harmful. Drop the connect listener; keep the disconnect path. Tests: updated the unit test to assert the new behavior (connect does not clear); added an integration regression that wires a sibling connect listener firing listTools and asserts the response folds into the request entry instead of appearing as an orphan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: drop accidentally tracked debugging screenshot pending-requests.png was a local debugging artifact attached to a conversation, not source. Remove it from the tree (the working copy is kept so the user still has it locally) and rely on the existing untracked behavior going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(servers): add e2e check that settings.headers reach upstream MCP Stitches the full v2 pipeline together in one assertion: browser-style RemoteClientTransport → POST /api/mcp/connect (settings in body) → backend createTransportNode applies settings.headers to requestInit → SDK StreamableHTTPClientTransport → upstream MCP test server Captures the upstream HTTP request via FetchRequestLogState and asserts both `X-Tenant` and `X-Trace` are present on the outbound POST. Catches any regression that breaks the wire between the form and the upstream server in a single integration test, instead of the previous coverage which exercised each hop in isolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ServerSettingsModal): invert ListToggle compact prop so icon matches state ListToggle's `compact` prop represents the list's *current* compact state: true means "currently compact" and renders the expand icon to advertise the action (open up). The settings modal was passing `compact={allExpanded}` — exactly inverted. When all sections were open it showed the expand icon (suggesting "open more") and when collapsed it showed the collapse icon, opposite of every other ListToggle caller in the app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(HistoryListPanel): match ServerListScreen Export button — rename + default variant Renames "Export JSON" to "Export" and switches from the subtle ToolbarButton to the same `Button variant="default"` used by ServerListControls so the two Export affordances look identical across screens. Also moves the button to the right of the ListToggle (mirroring the order on the server list screen) and disables it when there are no entries to export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(HistoryListPanel): move Clear button to top toolbar, match Export style Moves the Clear affordance out of the "History (N)" section header and up to the panel toolbar alongside Export, where it now uses the same `Button variant="default"` styling. Order in the toolbar is now: ListToggle · Clear · Export. Clear is disabled when there are no unpinned entries to remove (pinned entries are intentionally preserved). ToolbarButton helper is unused after this change and dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: ignore local debugging screenshots at repo root Stops `git add -A` from re-staging the `*.png` files Claude Code drops at the repo root from attached conversation screenshots. The pattern only matches the top-level (`/*.png`) so legitimate image assets in subdirectories (e.g. `clients/web/public/`) still get tracked when explicitly added. Also untracks the two screenshots that slipped through previously (initialize-request.png, pending-requests.png). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(LogStreamPanel): match HistoryListPanel button styling Clear / Export / Copy All on the Logging screen were rendered with the subtle ToolbarButton helper while History's equivalents use `Button variant="default"`. Switch all three to the default variant so the toolbars look identical across screens and mirror the disabling behavior — each button is now disabled when `entries.length === 0`. ToolbarButton helper is unused after this change and dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR review — debounce save, preserve-on-omit PUT, validate shape, lift connect timeout Round-up of fixes from https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4526861959: 1. ServerSettingsModal saves were firing on every keystroke (one PUT + full mcp.json refresh per character). App.tsx now debounces by 300ms and flushes on modal close so a burst of edits coalesces into one PUT and the form value can't flicker back mid-typing. 2. `PUT /api/servers/:id` with an omitted `settings` field used to silently wipe the persisted settings node. The route now treats the three intents distinctly: - field omitted → preserve existing on-disk settings - explicit null → clear the settings node - settings object → validate + apply `useServers.updateServer` is simplified to drop the client-side carry-through hack, and the test that documented the broken behavior is flipped + augmented with explicit-clear and bad-shape cases. 3. `connectionTimeout` Promise.race is moved from App.tsx into `InspectorClient.connect()`, reading `this.serverSettings?.connectionTimeout`. TUI/CLI consumers now get the same behavior; on timeout the client internally disconnects so the next connect starts clean. 4. Custom Headers hint in `ServerSettingsForm` notes that `Authorization` is owned by the OAuth flow when OAuth is configured — avoids the sharp edge where the SDK's authProvider silently overwrites a user-set Authorization header. 5. New `validateSettings` on the server route rejects malformed shapes (`settings: []`, `settings.headers: "oops"`, non-numeric timeouts, etc.) with 400 instead of letting them persist and crash the UI. 7 (nit). Renamed `settings` → `savedSettings` inside `setupClientForServer` so the variable holding the persisted settings doesn't shadow the later locally-derived values. Follow-ups opened separately: #1356 — Move oauthClientSecret out of mcp.json into the OS keychain (review #6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR review pass 2 — connect-timeout test, unmount cleanup, tagged intent, lenient settings read Pass-2 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4527089705: (a) New integration test pins the new client-layer connect-timeout contract: a hanging stub transport with `connectionTimeout: 50` rejects with the descriptive error and lands the client in "error" status. Documents what TUI/CLI consumers should expect from the lifted Promise.race in InspectorClient.connect(). (b) useEffect cleanup clears `pendingSettingsTimerRef` on unmount so a route change or HMR mid-debounce can't fire one stale PUT against an unmounted component. (c) Refactor the PUT-route `settingsIntent` discriminator from `"preserve" | "clear" | { value }` to a tagged union with `kind`, and consume it with a `switch` for exhaustiveness — reads cleaner and makes adding a fourth intent painless. (d) `normalizeMcpServers` now runs `validateSettings` on the on-disk settings node and silently strips it if invalid, mirroring the existing lenient-on-read pattern from `normalizeServerType`. Closes the read/write asymmetry where a hand-edited malformed `settings` could survive on disk and get carried through preserve-on-PUT. New route test pins the strip-on-GET + no-propagate-on-preserve-PUT behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-3 review — patch-style PUT, toast on close-flush fail Pass-3 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529276897. (b) `PUT /api/servers/:id` now treats both `config` and `settings` as optional patches: - field omitted → preserve the on-disk value - explicit body value → apply - settings: null → clear `updateServerSettings` stops reading `existing.config` from the in-memory `servers` snapshot, which closes the closure-capture hazard where a refresh between debounce-schedule and flush could silently revert a separate edit. The route is the single source of truth for transport config now. New tests cover settings-only PUT, rejected non-object config, and the empty-body no-op patch case. The previous "rejects a missing config" test is rewritten to document the new no-op-preserve semantics. (c) Inline comment near the PUT rebuild loop documents that writing the full mcpServers map back is what gives the route its desirable self-healing property for malformed settings on *other* entries — deliberate side-effect, not an accident of normalization. (d) `flushPendingSettings` surfaces failures via @mantine/notifications instead of swallowing them. The modal already closed by the time the flush PUT resolves, so a silent fail would leave the user thinking their last edits saved (especially painful for the OAuth client secret). The notifications wiring was already mounted in main.tsx. Pass-3 (a) — settings form being effectively uncontrolled mid-edit — is acknowledged in the reply: working as intended today; the proper fix is internal modal state when file-watching (#1345) forces the issue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-4 review — strip smuggled settings out of config Pass-4 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529861349. Main finding: `normalizeServerType` spreads unknown keys, so a body that nested `settings` inside `config` would smuggle a settings field onto the stored entry without ever passing through `validateSettings`. Strip the settings key off the incoming config in `buildStoredEntry` so `validateSettings` remains the single write path for the settings node — the "one source of truth" invariant pass 2 set up holds again. Two new tests pin the strip on both routes: - POST with `config.settings = { bogus }` lands an entry with no settings node on disk. - PUT with `config.settings = { bogus }` + `settings: null` clears the real settings *and* doesn't re-attach the bogus payload via the spread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-5 review — absorb late connect rejection, tighten validateSettings, log smuggle warning Pass-5 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529954490. Main finding: when the connect-time `Promise.race` lost to the timeout, the original `this.client.connect(this.transport)` promise was left pending. After `disconnect()` tore the transport down, real transports (SSE / streamable-http) would reject that promise later — with no handler attached, producing a Node `unhandledRejection` (and a vitest suite warning). Attach a no-op `.catch` right after creating the connect promise so Node sees the late rejection as handled. Nit (a): `validateSettings` was casting the raw object through to `InspectorServerSettings`, which let unknown keys ride along onto disk. Replaced the cast with an explicit pick-and-build over the known fields. Unknown stowaways now silently drop on the way through. New route test pins the behavior. Nit (b): `buildStoredEntry` now logs a `warn` via the route's pino logger when an incoming `config` carries a `settings` key. The strip is still defensive (it isn't a documented contract) but the warning lets a misconfigured client find out about the wasted field instead of debugging a silent drop. Nit (c) — the defensive `?? {}` fallback in `buildStoredEntry` — acknowledged in the review reply; left in place as belt-and-braces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-6 polish — scope absorber, thread id, drop empty OAuth, guard `in` Pass-6 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529979135. Reviewer signed off as ready; landing the four marginal cleanups since they're all small. (a) `connectPromise.catch(() => {})` moves inside the `if (connectTimeoutMs > 0)` branch. The await-only path propagates rejection cleanly through the outer try/catch — the absorber is only needed when the race leaves the original promise unattended. Code now reads as "absorber is tied to the race," which is the real invariant. (b) Thread `id` (POST id, PUT newId) through `buildStoredEntry` and include it in the smuggle warning's pino bindings. Two clients smuggling simultaneously are distinguishable in the log instead of indistinguishable. (c) `validateSettings` coerces empty-string OAuth fields to absent. The form emits `""` when the user clears an input, and an empty `oauthClientId` on disk would later be misread as "OAuth configured." New route test pins it. (d) `buildStoredEntry` adds a null/object guard before `"settings" in configObj`. The route layer pre-checks this on POST and PUT, but the helper's `unknown` parameter is a weak contract; the guard makes it safe to call from anywhere. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): reflect settings persistence + patch-PUT contract in v2_servers_file The doc was authored when `mcp.json` only stored the server list. Bring it forward to match what shipped in #1352 / PR #1353: - Summary calls out that the file also stores per-server settings and links into the Per-server settings section. - On-disk format note explains that `settings` is an optional Inspector- specific extension that other MCP tools ignore. - New top paragraph on the Per-server settings section explains the basic-config / settings UI split rationale (simpler form for the common case, advanced settings tucked behind a second dialog). - UI bullet is rewritten to match the patch-style PUT semantics that emerged across the review cycle: settings-only and config-only PUTs preserve their counterpart inside the write lock, so neither modal can silently clobber the other half. The earlier "must re-send settings on PUT" hack is gone. - New bullets capture the save cadence (300ms debounce + flush-on-close + failure toast), the patch-PUT contract (omit-preserve / null-clear / rename), and the write-path / read-path validator gates. - Secret storage bullet links to #1356 (OS-keychain follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): show a third entry with the optional settings node The On-disk format example demonstrated only basic stdio entries. Add a streamable-http entry with a populated `settings` node so readers can see the shape inline rather than having to scroll to the Per-server settings section. Inline comment in the block cross-links there for the full contract and notes that other MCP tools ignore the extension key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
Inspector V2 Tech Stack - Storage - Server List File
Brief | V1 Problems | V2 Scope | V2 Tech Stack | V2 UX
Web Client | Server | Storage
Overview | Server List File
Summary
Replaces the hardcoded SEED_SERVERS in clients/web/src/App.tsx:47 with a file-backed list at ~/.mcp-inspector/mcp.json, read at startup, mutated via REST endpoints, surfaced through a useServers hook. The file also stores the per-server settings (custom headers, request metadata, timeouts, pre-configured OAuth credentials) edited by ServerSettingsForm — see Per-server settings below for the on-disk shape, UI rationale, and write/read invariants.
Goals
- Persist the user's server list across restarts.
- Use the canonical
{ mcpServers: { ... } }format so the file is interoperable with Claude Desktop / Cursor / Cline and editable by hand. - Reuse the file-I/O facility already ported from v1.5 (
core/storage/store-io.ts) and the parser already incore/mcp/node/config.ts. - Land full CRUD in one pass (per the scope decision) so the
onServerAdd/onServerEdit/onServerClone/onServerRemovestubs inApp.tsx:639stop lying.
Non-goals
- Sync with Claude Desktop's
claude_desktop_config.jsonlocation. We pick our own path; symlinking is the user's call. - Server schema validation beyond what
loadMcpServersConfigalready does (structural; no command-existence check). - Multi-user / multi-machine sync.
- Migrating CLI/TUI to the default path — they already accept
--config <path>viacore/mcp/node/config.ts. The new default-path helper will be in core so they can adopt it later.
File location
- Path:
~/.mcp-inspector/mcp.json(Windows:%USERPROFILE%\.mcp-inspector\mcp.json). - Why this dir:
~/.mcp-inspector/storage/already exists for the Zustand-persist stores (OAuth, settings); one Inspector dir under$HOMEis friendlier than two. Resolution uses the sameprocess.env.HOME || process.env.USERPROFILEfallback asgetDefaultStorageDir()incore/storage/store-io.ts:13. - Why canonical filename: lets users symlink to/from Claude Desktop and similar tools.
- Permissions:
0o600, matchingwriteStoreFileincore/storage/store-io.ts:55.
On-disk format
{
"mcpServers": {
"filesystem-server-default": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
},
"everything-server-default": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
},
"acme-api": {
"type": "streamable-http",
"url": "https://api.acme.example/mcp",
// Optional Inspector-specific extension. Other MCP tools
// (Claude Desktop, Cursor, Cline) ignore unknown keys. See
// [Per-server settings](#per-server-settings-1352) for the
// full contract.
"settings": {
"headers": [{ "key": "X-Tenant", "value": "acme" }],
"metadata": [{ "key": "trace", "value": "abc" }],
"connectionTimeout": 30000,
"requestTimeout": 60000,
"oauthClientId": "client-abc",
"oauthScopes": "read:tools write:tools"
}
}
}
}
- Matches
MCPConfigincore/mcp/types.ts:68. typeomitted → normalized to"stdio";type: "http"→"streamable-http"(normalizeServerTypeincore/mcp/node/config.ts:81).- The map key is the server
id.ServerEntry.idalready documents itself this way (core/mcp/types.ts:89: "The MCPConfig.mcpServers map key"). - Display name: derived from the map key. The edit dialog treats id and display name as the same field; renaming = key-rotate + carry config across.
- Each entry may optionally carry a
settingsnode alongside the transport keys (headers,metadata, timeouts, OAuth credentials — see Per-server settings below). It is an Inspector-specific extension; other MCP tools (Claude Desktop, Cursor, Cline) treat it as an unknown key and ignore it.
First-run behavior
If the file does not exist when the backend boots, write a file containing the two current SEED_SERVERS. User immediately sees a non-empty Servers screen and discovers the file by editing one of the seeds. Subsequent boots read whatever the user has saved.
Architecture
Reused
| Concern | File | What we reuse |
|---|---|---|
Atomic R/W + ENOENT handling + 0o600 + mkdir -p |
core/storage/store-io.ts |
readStoreFile, writeStoreFile, deleteStoreFile, parseStore, serializeStore |
mcp.json parsing + type normalization |
core/mcp/node/config.ts |
loadMcpServersConfig (already used by the CLI/TUI runner code); normalizeServerType needs to be exported |
| Hono backend + auth + storage routes pattern | core/mcp/remote/node/server.ts |
/api/storage/:storeId is the template for the new /api/servers routes |
| Auth'd fetch from browser | wired via getAuthToken() in clients/web/src/App.tsx:84 |
useServers will call the backend with x-mcp-remote-auth: Bearer <token> |
Why not createFileStorageAdapter directly
core/storage/adapters/file-storage.ts is a Zustand persist adapter — it wraps the payload as { state, version } so the file ends up looking like {"state":{...},"version":0}. That breaks the "human-editable canonical mcp.json" goal. We use the underlying store-io.ts primitives instead.
New code
core/storage/store-io.ts (extend)
export function getDefaultMcpConfigPath(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
return path.join(homeDir, ".mcp-inspector", "mcp.json");
}
Co-located with getDefaultStorageDir() so the two path conventions stay in one file.
core/mcp/serverList.ts (new)
Pure converters between on-disk MCPConfig and in-memory ServerEntry[]. No I/O — easy to unit-test under happy-dom.
export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[];
export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig;
export function DEFAULT_SEED_CONFIG: MCPConfig; // the two existing seeds
mcpConfigToServerEntries sets connection: { status: "disconnected" } and uses the map key as both id and name. serverEntriesToMcpConfig strips connection / info (runtime-only) before serializing.
Also re-export normalizeServerType from core/mcp/node/config.ts (or move it into serverList.ts and import it back into config.ts).
core/mcp/remote/node/server.ts (extend)
Add granular endpoints (mirror of /api/storage/:storeId, but specialized so the UI can do per-row mutations without read-modify-write across tabs):
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/api/servers |
— | { mcpServers: {...} } — creates the file with seeds if absent |
POST |
/api/servers |
{ id: string, config: MCPServerConfig } |
{ ok: true }; 409 if id already exists |
PUT |
/api/servers/:id |
{ id?: string, config: MCPServerConfig } |
{ ok: true }; supports id rename (delete old key + write new) |
DELETE |
/api/servers/:id |
— | { ok: true } (ignores missing) |
id is validated with validateStoreId (same alphanum+hyphen+underscore rule as store IDs — prevents anyone slipping .. into the key). All routes serialize through the same atomic writeStoreFile, so concurrent writes are well-defined (last writer wins per-write; granularity reduces blast radius).
RemoteServerOptions gains:
/** Optional path for the user's server list file. Default: ~/.mcp-inspector/mcp.json */
mcpConfigPath?: string;
Defaulted via getDefaultMcpConfigPath(). clients/web/server/vite-hono-plugin.ts:62 and clients/web/server/server.ts:48 get a one-line update to pass config.mcpConfigPath if/when the web config grows the field; for v1, the default is sufficient.
core/react/useServers.ts (new)
export interface UseServersResult {
servers: ServerEntry[];
loading: boolean;
error: string | undefined;
refresh: () => Promise<void>;
addServer: (id: string, config: MCPServerConfig) => Promise<void>;
updateServer: (originalId: string, newId: string, config: MCPServerConfig) => Promise<void>;
removeServer: (id: string) => Promise<void>;
}
export function useServers(opts: {
baseUrl: string; // window.location.origin by default in callers
authToken: string | undefined;
}): UseServersResult;
Fetches on mount via fetch(${baseUrl}/api/servers, ...) with the auth header. Holds the list in useState. Mutators do the HTTP call then re-fetch to keep server-and-client in sync (the list is small, ~tens of entries; optimistic merging is not worth the bug surface).
clients/web/src/App.tsx changes
- Remove the
SEED_SERVERSconstant (seeds move tocore/mcp/serverList.tsasDEFAULT_SEED_CONFIG). - Replace
const [servers] = useState<ServerEntry[]>(SEED_SERVERS);withconst { servers, addServer, updateServer, removeServer } = useServers({ baseUrl: window.location.origin, authToken: getAuthToken() });. - Wire
onServerAdd/onServerEdit/onServerClone/onServerRemove(currentlytodoNoopatclients/web/src/App.tsx:639–646) to the hook. - Disconnect-on-remove: if the user removes the
activeServerId, callinspectorClient?.disconnect()and clear active server state before the mutation. Matches the lifecycle theuseEffectatApp.tsx:272already enforces on unmount. - Active-server pinning across rename:
updateServerreturns the new id; iforiginalId === activeServerId, updateactiveServerIdto the new id.
UI surfaces
The InspectorView prop interface already declares onServerAdd / onServerEdit / onServerClone / onServerRemove / onServerImportConfig / onServerImportJson. The dialogs themselves are TBD — out of scope for this spec is the visual design; in scope is wiring them to the new hook. If the Add/Edit dialog component does not exist yet, ship a minimal Mantine Modal + TextInput + transport-specific fields. Follow clients/web/src/components/... conventions (subcomponent constants via .withProps(), theme variants for styling — per AGENTS.md's React rules).
onServerImportConfig / onServerImportJson map naturally to "paste a full mcpServers block" and "upload an mcp.json file"; both become bulk POST /api/servers calls in a loop (or a single PUT /api/servers that we add later). Defer until basic add/edit/remove is working.
Test plan
Place tests per AGENTS.md's integration-folder convention.
Unit (unit vitest project, happy-dom)
clients/web/src/test/core/mcp/serverList.test.ts— round-tripMCPConfig↔ServerEntry[]; verifies the map key becomes the id, thatconnection/infoare stripped on serialize, and thatnormalizeServerTypeis applied on parse.clients/web/src/test/core/storage/getDefaultMcpConfigPath.test.ts— env-var permutations (HOMEset /USERPROFILEset / neither).
Integration (integration vitest project, node env, 30s)
clients/web/src/test/integration/mcp/remote/servers-route.test.ts— spin upcreateRemoteAppwith a tmpmcpConfigPath, exercise GET (file absent → seeds written; file present → returned), POST (success + 409 on dup), PUT (rename + payload update), DELETE (existing + missing). Mirrors theadapters.test.tspattern already inclients/web/src/test/integration/storage/adapters.test.ts.clients/web/src/test/integration/react/useServers.test.tsx— render the hook against a realcreateRemoteAppHono instance (no mocking); assert load, add, update, remove flows reflect what the backend has on disk.
Coverage
The 90% per-file gate applies to the new files. The pure converters and route handlers are easy; the React hook's error path needs explicit coverage (network error, 4xx response, 5xx response).
Manual
Per AGENTS.md's "test new or modified code" rule plus the UI-changes guidance: run npm run dev, verify (a) first launch writes the seeds, (b) editing the file by hand and reloading the browser shows the edit, (c) Add/Edit/Remove from the UI persist across a hard reload, (d) deleting the active server cleanly disconnects.
Risks
- Concurrent writes from multiple browser tabs. Granular endpoints reduce the surface (per-row, not whole-file). Same-row contention is last-write-wins, which is fine for a config the user is editing manually. We do not add file locking; the cost outweighs the rare case.
- User edits the file while the browser is open. Browser holds a stale list until the user hits Refresh on the Servers screen (the
refreshreturned by the hook). Acceptable for v1; auto-watching the file is a possible follow-up butfs.watchsemantics across OSes are a long tail of bugs. - Schema drift with Claude Desktop / Cursor. They occasionally add fields (e.g. Claude Desktop's
disabled).loadMcpServersConfigcurrently doesJSON.parse(...) as MCPConfig— extra fields survive the round-trip as long as we don't filter them. The converters inserverList.tsshould preserve unknown fields onMCPServerConfigrather than copying a fixed allow-list. - Migration from
SEED_SERVERS. Existing dev users have no file. First boot writes one — they won't notice. No code path persists the in-memoryuseStatelist today, so nothing to migrate.
Per-server settings (#1352)
Our new UI design separates the basic server configuration (transport, URL or command + args + env) from settings (custom headers, connect/request timeout, global request metadata, client id/secret) into two dialogs. The latter is stored in an optional settings node of the server config. The reason they're separated in the UI is that custom settings are less likely to be needed than basic config, so a simpler, friendlier form greets most users.
Each server entry may carry an optional settings node alongside the transport config:
{
"mcpServers": {
"my-server": {
"type": "streamable-http",
"url": "https://example.com/mcp",
"settings": {
"headers": [{ "key": "Authorization", "value": "Bearer xxx" }],
"metadata": [{ "key": "tenant", "value": "acme" }],
"connectionTimeout": 30000,
"requestTimeout": 60000,
"oauthClientId": "...",
"oauthClientSecret": "...",
"oauthScopes": "read:tools write:tools"
}
}
}
}
- Shape:
InspectorServerSettingsincore/mcp/types.ts. Thesettingsfield onStoredMCPServer(also intypes.ts) is the on-disk extension; other MCP tools (Claude Desktop, Cursor, Cline) ignore unknown fields. - Where it takes effect:
settings.headers→ wire headers on every SSE / streamable-http request (core/mcp/node/transport.ts).settings.metadata→ default_metapayload merged into every outgoing MCP request (core/mcp/inspectorClient.ts'smergeMetahelper). Per-call metadata wins on key collision.settings.requestTimeout→InspectorClientOptions.timeout.settings.connectionTimeout→Promise.racewrapper aroundInspectorClient.connect()in the web client.settings.oauthClientId/oauthClientSecret/oauthScopes→ pre-seeded OAuth client credentials viaInspectorClientOptions.oauth.
- First-connect contract: settings apply on the first outbound request after the entry loads from disk — no need to open the settings form. The browser sends
settingsto the backend in the/api/mcp/connectbody; the backend reads it fromRemoteConnectRequestand threads it intocreateTransportNode. - Secret storage:
oauthClientSecretis persisted inmcp.jsonalongside stdioenvvalues, both protected by the file's0o600permission. OS-keychain integration is tracked separately in #1356 — when that lands, the secret will be lifted out of the file and replaced with a keychain reference. - Removed:
MCPServerConfig.headers(previously onSseServerConfig/StreamableHttpServerConfig) has been deleted. The headers textarea inServerConfigModalis gone; HTTP headers are entered only inServerSettingsForm. v2 has not shipped a stable release with the old shape, so no on-read migration is included. - UI:
ServerSettingsModalis opened from the server card's settings affordance. Saving routes throughuseServers.updateServerSettings(id, settings)which issues a settings-onlyPUT /api/servers/:idwith{ id, settings }— the route preserves the on-disk transport config inside its write lock. Conversely,useServers.updateServer(driven by the basic-config modal) issues a config-only PUT with{ id, config }and the route preserves the on-disk settings node. Edits in either modal cannot silently wipe the other half. - Save cadence: the form fires
onSettingsChangeon every keystroke.App.tsxdebounces 300 ms and flushes on modal close so a burst of edits coalesces into a single PUT. If the close-flush PUT fails (network hiccup, server 500), a red@mantine/notificationstoast surfaces the failure — the modal has already closed so a silent failure would leave the user thinking the last edits saved. PUT /api/servers/:idpatch semantics: bothconfigandsettingsare independent patches.- Field omitted → preserve the on-disk value.
- Explicit
nullonsettings→ clear the settings node. (configmay not benull; a body that wants to update only settings should omitconfigentirely.) - Field present and well-formed → validate and apply.
- A bare
PUT { id: "renamed" }is a pure rename preserving both halves.
- Write-path gates:
validateSettingsrejects malformed shapes (non-object, wrong-typedheaders/metadata, non-numeric timeouts) with400+ descriptive message and picks-and-builds the validated value so unknown stowaway keys silently drop.buildStoredEntrystrips anysettingskey nested inside the incomingconfig(and logs awarnwith the server id) sovalidateSettingsremains the only path settings reach disk on the write side. - Read-path gates:
normalizeMcpServersre-runsvalidateSettingson the on-disk settings node when loading; a malformed hand-editedsettingsnode is silently dropped (lenient-on-read pattern matchingnormalizeServerType's unknown→stdio fallback). A subsequent preserve-on-PUT cannot propagate the broken node, andmcp.jsonfiles written by future versions with unknown stowaway keys won't poison the in-memory shape.
Out of scope (follow-ups)
- Import-from-Claude-Desktop button (read
~/Library/Application Support/Claude/claude_desktop_config.jsonor the Windows/Linux equivalent, merge into our file). - File watching for hot reload of external edits.
- Per-server tags / folders / groups.
- Export current list as JSON.
- CLI/TUI: switch their default
--configtogetDefaultMcpConfigPath()when no--configflag is given. Touch when those clients are wired up to v2. While porting, re-add a--headerflag that writes tosettings.headersrather than toMCPServerConfig.