* feat(servers): move oauthClientSecret and stdio env values to OS keychain (#1356) mcp.json is designed to be tool-shareable (symlinked from Claude Desktop's config, pasted into bug reports, synced via dotfiles), so storing OAuth client secrets and stdio env values in plaintext there meant any of those flows could leak them. Lift both into the OS keychain via @napi-rs/keyring (active replacement for the archived keytar). The wire shape is unchanged: the GET /api/servers handler rehydrates from the keychain so browser code sees the same JSON; the on-disk file no longer contains the secret material. Includes an idempotent migration that lifts plaintext from older mcp.json files (or hand-edited ones) into the keychain on first read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): tolerate keychain unavailability on non-set ops (#1356) CI on Linux failed because `@napi-rs/keyring` hard-errors without libsecret, and the route handlers were touching the keychain even for no-secret flows (defensive `deleteAllForServer` on POST/DELETE, sweep on PUT). The user's intent for the Linux fallback was "hard fail when a secret is actually involved" — not on every keychain touch. Make `KeyringSecretStore.get / delete / deleteAllForServer` silently degrade when the keychain is unavailable (return null / no-op); `set` remains the one operation that hard-fails with `KeychainUnavailableError`. The migration path catches the error and preserves the disk plaintext rather than losing the secret. Also inject `InMemorySecretStore` in `useServers.test.tsx` and `servers-events.test.ts` so tests never touch a developer's real keychain even when libsecret is present. Adds `KeyringSecretStore` tests (vi.mock'd native bindings) and an integration suite that simulates Linux-without-libsecret to verify the tolerance contract end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): close GET migration race; reverse keychain/disk order (#1356) Addresses code review: 1. **Concurrency**: GET /api/servers was reading + migrating outside the write lock, then writing inside it — a concurrent POST/PUT/DELETE between the read and the lock acquisition could be clobbered by the migration write. Also affected the first-launch seed write. Fix: take a fast unlocked peek; if the file is absent (seed branch) or carries plaintext to migrate (migration branch), re-read inside the write lock and decide there. Added `hasPlaintextSecrets()` as a pure predicate so the common no-migration path stays lock-free. 2. **Write ordering**: POST and PUT wrote disk first, then keychain. A keychain `set` failure mid-write left a half-configured entry on disk and trapped retries at 409. Reversed to keychain-first so a 503 leaves both stores in their pre-write state. PUT in-place now sets new fields, writes disk, then deletes obsolete fields (a failed disk write leaves orphan keychain entries — recoverable — rather than premature deletions of fields the user still expects to see). 3. **Tolerance simplification**: dropped the no-entry regex in `KeyringSecretStore.delete` — both "no entry" and "keychain unavailable" collapse to the same desired outcome (the entry isn't there anymore), so a uniform swallow is clearer. 4. **Parallelism**: `readKeychainEntriesFor` now `Promise.all`s the per-field `get` calls. macOS Keychain round-trips are 10-50ms; serializing 5+ env vars per server × N servers is a meaningful wall-clock cost on GET. 5. **Spec doc**: reflects the tolerance contract (only `set` hard-fails) and the keychain-before-disk write ordering rationale. New regression test in servers-route.test.ts covers the retry-after-503 contract directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(servers): parallel keychain writes + migration partial-state comment (#1356) Two nice-to-have observations from the re-review: - `writeKeychainEntriesFor` now `Promise.all`s its per-field sets, matching the read side. No ordering requirement among distinct (id, field) keys; on unavailability every set rejects and Promise.all surfaces the first rejection (still 503 from the route handler). - `migratePlaintextSecrets` catch block now spells out the partial-migration semantics: we return the original config so the disk plaintext stays intact, and the next successful GET retries with the keychain-wins branch absorbing already-set entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(servers): parallel keychain deletes for symmetry (#1356) Picks up the symmetry observation from the third review: `deleteKeychainFields` now uses `Promise.all` like its read and write siblings. Distinct (id, field) deletes have no ordering requirement, and `secretStore.delete` is already a silent no-op on unavailability so Promise.all has no failure-mode surprise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 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. Post-#1358 those settings fields live as direct keys on the entry (matching the Claude Code / Cursor / Cline .mcp.json convention) rather than under a nested settings block.
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",
// Inspector-extension fields (post-#1358) live as direct keys on the
// entry, matching the Claude Code / Cursor / Cline `.mcp.json`
// convention. See [Per-server settings](#per-server-settings-1352)
// for the full contract; the in-memory + wire shape keeps pair-array
// headers and flat oauth* fields for the form's controlled-component
// editing.
"headers": { "X-Tenant": "acme" },
"metadata": [{ "key": "trace", "value": "abc" }],
"connectionTimeout": 30000,
"requestTimeout": 60000,
"oauth": {
"clientId": "client-abc",
"scopes": "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 Inspector-extension fields (
headers,metadata,connectionTimeout,requestTimeout,oauth) as direct keys on the entry — post-#1358 these are no longer nested under asettingswrapper. Theheadersandoauthshapes match the Claude Code / Cursor / Cline.mcp.jsonconvention, so a file written by any of those tools is loadable on first connect.metadata/connectionTimeout/requestTimeoutare Inspector-only and other tools simply ignore them.
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, flattened in #1358)
Our 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 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.
#1352 originally persisted these settings under a nested settings block on each entry. #1358 flattens them onto the entry as direct keys, so the on-disk shape matches the .mcp.json convention Claude Code / Cursor / Cline use (headers as a Record<string, string>, oauth as a nested object). The in-memory + wire shape is unchanged: InspectorServerSettings keeps pair-array headers and flat oauthClientId / oauthClientSecret / oauthScopes because the form needs them in that shape for controlled-component editing.
Each server entry may carry these Inspector-extension fields at the top level:
{
"mcpServers": {
"my-server": {
"type": "streamable-http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" },
"metadata": [{ "key": "tenant", "value": "acme" }],
"connectionTimeout": 30000,
"requestTimeout": 60000,
"oauth": {
"clientId": "...",
"clientSecret": "...",
"scopes": "read:tools write:tools"
}
}
}
}
- Shape:
- On disk (
StoredMCPServerincore/mcp/types.ts):headersasRecord<string, string>,metadataas a pair-array (Inspector-only — no compat target), numeric timeouts,oauthas a nested{ clientId?, clientSecret?, scopes? }object. Every field optional; absent fields are omitted on disk so the file diff stays minimal. - In memory + on the wire (
InspectorServerSettingsincore/mcp/types.ts):headersas a pair-array{ key, value }[], flatoauthClientId/oauthClientSecret/oauthScopesfields, requiredmetadata(pair-array) + required numericconnectionTimeout/requestTimeout(the form needs concrete values to render; 0 is the SDK's "no timeout" signal). - The bidirectional conversion lives in
core/mcp/serverList.ts(storedFieldsToInspectorSettings/inspectorSettingsToStoredFields) and is invoked bymcpConfigToServerEntries/serverEntriesToMcpConfigand by the server route'sbuildStoredEntry.
- On disk (
- Where it takes effect:
headers→ wire headers on every SSE / streamable-http request (core/mcp/node/transport.tsconsumes the pair-array viaInspectorServerSettings.headers).metadata→ default_metapayload merged into every outgoing MCP request (core/mcp/inspectorClient.ts'smergeMetahelper). Per-call metadata wins on key collision.requestTimeout→InspectorClientOptions.timeout.connectionTimeout→Promise.racewrapper aroundInspectorClient.connect()in the web client.oauth.clientId/oauth.clientSecret/oauth.scopes→ pre-seeded OAuth client credentials viaInspectorClientOptions.oauth(the disk-sideoauthobject is lifted into the flatoauthClientId/ etc. fields onInspectorServerSettingsfor the form).
- 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 (#1356):
oauth.clientSecretand stdioenvvalues are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via@napi-rs/keyring), keyed by(serverId, field)under the service namemcp-inspector. Field names:oauth-client-secret,env:<KEY>(one per stdio env variable). The on-diskmcp.jsonis stripped of these values —oauth.clientSecretis omitted entirely, stdio env keys are preserved with empty-string placeholders ("env": { "API_KEY": "" }) so the file still documents the env interface the server expects. The wire shape returned byGET /api/serversis unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. The keychain interactions live incore/auth/node/secret-store.tsbehind aSecretStoreinterface;KeyringSecretStoreis the production impl andInMemorySecretStoreis the test double the integration suite injects viaRemoteServerOptions.secretStore.- Migration: on every
GET /api/servers, the handler walks the freshly-read config and, for any entry that still carries plaintext secrets (older Inspector builds, hand-edited files, files imported from another tool), lifts each value into the keychain and rewrites the file with the stripped shape. The migration is idempotent — when the keychain already holds a value for(serverId, field), the keychain wins and the disk plaintext is dropped unread. After the rewrite the disk file no longer contains the secret material. - Linux without libsecret:
KeyringSecretStoreis tolerant — only thesetoperation throwsKeychainUnavailableError(translated to a503by the handlers);getreturnsnulland the destructive operations silently no-op. The result is that no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) all work normally on a minimal Linux box without libsecret. Only the moments where a secret would actually be lost — saving an OAuth client secret, saving a stdio env value, or migrating a plaintext value into the keychain — surface a clear error. macOS and Windows always have a working keychain so this only matters on minimal Linux installs. - Migration tolerance: when migration encounters
KeychainUnavailableError, the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry — installing libsecret later lifts the secrets on the next GET without any user action. - Write ordering on POST/PUT: keychain writes happen before the disk write, and obsolete-field deletions happen after. The intent is that a
setfailure (the only hard-fail path) leaves both stores in their pre-write state — no half-applied entry on disk that would trap a retry POST at409, and no premature deletion of an obsolete field whose disk write later fails. - Out of scope for this PR: the OAuth handshake itself still runs in the browser via the MCP SDK, so during the token exchange the secret transits the wire (browser → MCP SDK → OAuth provider's token endpoint). The on-disk win this PR delivers is that the secret is no longer in the shareable / symlinked
mcp.jsonand is no longer the source-of-truth on the filesystem. Moving the token exchange to the Node side is tracked separately.
- Migration: on every
- Hard-cutover legacy behavior (per #1358 decision 4): files written by the one pre-#1358 build of v2/main have a nested
settingsblock.normalizeMcpServersdrops the node on read and logs a one-line warn including the server id; the persisted headers / metadata / timeouts / OAuth credentials are intentionally lost on first read. Users re-enter them via the settings form (or hand-edit the file into the flat shape). v2 has not shipped a stable release with the nested shape, so the blast radius is the small set of v2/main dogfooders who edited per-server settings between #1353 merging and this change. - 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 fields. 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 (kept-envelope wire shape per #1358 decision 5): bothconfigandsettingsare independent patches on the wire, even though the on-disk shape has nosettingswrapper. The envelope-on-the-wire keeps the preserve/clear/apply semantics #1353 introduced — the backend splats validatedsettings.*into top-level disk keys when assembling the next on-disk shape.- Field omitted → preserve the on-disk value.
- Explicit
nullonsettings→ clear all Inspector-extension fields on disk (headers/metadata/connectionTimeout/requestTimeout/oauth). (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 any of the Inspector-extension keys (settings,headers,metadata,connectionTimeout,requestTimeout,oauth) smuggled inside the incomingconfigand logs awarnwith the server id, so the wire envelope'ssettingsfield remains the only path those values reach disk. - Read-path gates:
normalizeMcpServerspasses the entry's flat Inspector-extension fields through verbatim — the form'sstoredFieldsToInspectorSettingsdoes the lift into the in-memory pair-array / flat-OAuth shape. A legacy nestedsettingsblock triggers the hard-cutover drop described above.
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 to the entry's top-levelheadersfield on disk (post-#1358 flat shape) rather than toMCPServerConfig.