Copilot review round 2, both comments upheld. **ELOCKED must not degrade.** Exhausting the retries on a held lock means the lock is *working* and something else demonstrably holds it — so running the mutation anyway entered the exact interleaving the lock exists to prevent, and entered it knowing a concurrent writer was there. `set` now fails instead. Two supporting changes: - The retry budget went from ~3.3s to ~15s so it outlasts the 10s stale window. Refusing is only defensible because a *crashed* holder resolves by takeover first; with the shorter budget, one Inspector killed mid-save would have failed every later save on the box until someone deleted the lock by hand. Pinned by a new test that stages a dead holder's lock directly (mkdir + backdated mtime) and asserts the save succeeds. - Acquire is now probe-then-wait. `proper-lockfile` drives its whole acquire through `retry`, which re-attempts on *any* error — so with a 20-retry budget a read-only `$HOME` would have spent the full 15s re-issuing an identical failing `mkdir` on every save before degrading. One retry-less probe separates "held" (worth waiting out) from "unavailable" (not). **The comment overclaimed what proper-lockfile provides, and the reviewer was right about the mechanism.** Verified against lib/lockfile.js@4.1.2: on EEXIST it stats, and if stale it rmdirs and re-mkdirs *without* checking the directory it removed is the one it found stale — so a slow waiter can still delete a fast waiter's fresh lock and both proceed. That is the same race the hand-rolled version could not close, and it is not closable without renameat2(RENAME_EXCHANGE). What the library actually adds is that the loser is *detected*: its refresh tick compares the lock's mtime against the value recorded at acquire, so a compromised holder is told rather than proceeding silently. And what is genuinely exclusive is the case that matters — mkdir is atomic and a live holder refreshes its mtime, so its lock never goes stale and two running Inspectors are serialized. The residual window opens only after a holder dies without releasing, and the optimistic verify still covers it. file-lock.ts, README.md, specification/v2_servers_file.md and AGENTS.md all carried the overclaim; all four now state the narrower, true version. Also fixes a test that was quietly asserting the opposite of its name: its holder used stale: 60_000 while the waiter used 10_000, and staleness is judged by the *waiter's* threshold — so the holder was declared stale after 10s and the save succeeded by takeover. Not done: a two-process stale-takeover race test. It would be testing the library rather than this code, and is inherently nondeterministic; the honest response to that finding was to stop claiming the race is closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
38 KiB
Inspector V2 Tech Stack - Storage - Server List File
Brief | V1 Problems | V2 Scope | V2 Tech Stack | V2 UX | V2 Auth | V2 New Spec Impact
Web Client | CLI, TUI, Launcher | 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 holds runtime persistence files (OAuth tokens, installclient.json, etc.); 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 a { state, version } envelope for mcp.json
Older OAuth persistence used a middleware-style envelope { state, version } around the payload. That shape is fine for opaque runtime blobs like oauth.json (still accepted on read for migration — see OAuth persistence) but breaks the goal of a human-editable canonical mcp.json. Server list I/O uses the underlying store-io.ts primitives and writes plain JSON.
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 (rewrites mcpServers key; migrates keychain secrets — see §Secret storage) |
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 + keychain secret migration on rename), 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 on
mcp.jsonis last-write-wins, which is fine for a config the user is editing manually. We do not lockmcp.json; the cost outweighs the rare case. The secrets file is a separate question with a separate answer — see Secret storage — because losing a write there loses a credential rather than a preference. - 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. TUI/CLI rehydration: the sharedloadServerEntries()path (core/mcp/node/servers.ts) callsrehydrateMcpConfigFromKeychain()(core/mcp/node/server-secrets.ts) after readingmcp.json, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the webGET /api/serversmigration sweep). The keychain interactions live incore/auth/node/secret-store.tsbehind aSecretStoreinterface. As of #1950 there are three implementations —KeyringSecretStore,FileSecretStore,InMemorySecretStore— chosen per run bycore/auth/node/secret-store-selection.ts; the integration suite still injects one directly 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 selected secret store and rewrites the file with the stripped shape. The migration is idempotent — when the store already holds a value for(serverId, field), the store wins and the disk plaintext is dropped unread.- Post-#1950 the rewrite is gated on durability. The disk copy is stripped only once the value is somewhere that outlives the process (
secretStoreIsDurable). Against the session-scoped in-memory store — the container fallback — stripping would trade a secret that survives restarts for one that dies with the process, and because this runs on an ordinaryGET, merely opening the app would destroy it. The values are still loaded into the store, so the session behaves normally; only the delete is withheld, and a one-line warning says so. - The "keychain wins" lookup uses the strict read.
getis tolerant by contract, so a transient store failure answersnull— and this branch writes onnulland then strips the disk copy, which would let an older on-disk value replace a newer stored one.secretStoreGetStrictthrows instead, and the migration abandons rather than writing on an answer it cannot trust.
- Post-#1950 the rewrite is gated on durability. The disk copy is stripped only once the value is somewhere that outlives the process (
- Linux without libsecret: every
SecretStoreshares one tolerance contract —getreturnsnull, the destructive operations silently no-op, and onlysethard-fails (with aSecretStoreUnavailableError, translated to a503). So no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) work normally on a box with no reachable keychain. Since #1950 that is no longer the end of the story: a host without a keychain now falls back to a file or in-memory store rather than being unable to persist a secret at all — see Secret store selection (#1950). - Migration tolerance: when migration encounters a
SecretStoreUnavailableError—KeychainUnavailableErroris one subclass, and the file store raises its own for an unreadable file, a changed passphrase or a write failure — the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry, so installing libsecret (or restoring the passphrase) lifts the secrets on the next GET with no user action. Partial-migration semantics are deliberate: ifsetthrew partway through the loop the handler returns the original config rather than the partially-rewritten one, so the disk file stays intact and the idempotent store-wins branch absorbs the already-written entries on retry. - 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. - Id rename (
PUTwith newid): secrets are keyed by server id (themcpServersmap key), not URL. On rename the handler reads existing keychain entries for the original id (expectedSecretFields+readKeychainEntriesFor), merges them with any secrets in the PUT body (body wins on conflict), filters to fields the new on-disk entry still expects (so removed stdio env keys are not carried over), writes under the new id, updates disk, thendeleteAllForServer(originalId). This matters for config-only renames (e.g. Server Config modal) that do not re-send OAuth client secrets or stdio env values — those live only in the keychain after #1356. Covered byservers-route.test.ts(PUT rename moves … when it exists only in the keychainfor OAuth and stdio env). - 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
-
Secret store selection (#1950): #1356 assumed a keychain. On a host without one — the published container (no D-Bus session, #1848), Android/Termux (no prebuilt binary, #1905), a minimal Linux install —
sethard-failed, so those users could not persist an OAuth client secret or a stdioenv:value at all. #1950 adds the two missing implementations plus the policy that picks one, and the surfaces that say which was picked.- Selection:
MCP_INSPECTOR_SECRET_STORE=keyring|file|memorywins outright. Otherwise the keychain is probed — anAsyncEntryconstruction plus a real read, because the container that motivated #1848 imports the package fine and only fails when it reaches for a Secret Service that isn't there. A read and not a write, deliberately: probing by writing would deposit a value in the user's login keyring at every startup for a store they may never use. If the probe fails the fallback ismemoryin a container whose secrets directory is not on a mount, andfileotherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a filedocker run --rmwill discard. Resolution is cached per process so the startup banner,GET /api/config, and the store doing the writing cannot disagree. FileSecretStoreat rest: one JSON document at~/.mcp-inspector/secrets.json(orMCP_INSPECTOR_SECRET_FILE, else underMCP_STORAGE_DIR), written0600and re-tightened at startup. WithMCP_INSPECTOR_SECRET_KEYset it is AES-256-GCM with a scrypt-derived key and a per-write random salt; without it the values are in the clear, and that is what the loud banner and the warning-toned modal footer are about. The whole map is encrypted as a unit, not value-by-value, so the account names (serverId:field) are hidden too — a per-value scheme would leave a readable index of which servers you hold a client secret for. Upgrading is lazy: setting the passphrase later re-encrypts on the next write rather than rewriting the file during a run that may never touch a secret, and the descriptor reportspendingEncryptionuntil it does. A file that can no longer be decrypted reads as empty but refuses to be written, because replacing a file of still-valid secrets to satisfy an additive request destroys data.- Concurrency: within a process, mutations are serialized per resolved file path (not per store instance — two
FileSecretStores on one file are ordinary, since the resolved store holds one and the keychain hand-off builds another). Across processes, each mutation holds an exclusiveproper-lockfilelock onsecrets.json.lockfor the whole read-modify-write (#2082). The in-process queue is what keeps that usable:proper-lockfileis not reentrant, so a secondlock()from the same process failsELOCKEDand would be indistinguishable from a genuine remote holder. An earlier iteration hand-rolled the lock — amkdirelection with an owner stamp, heartbeat and stale-takeover — and three review rounds each found a real race, the last not closable with what Node exposes (claiming a stale lock needs compare-and-swap on a directory entry,renameat2(RENAME_EXCHANGE)). #2082 settled that as "borrow, don't hand-roll" — but the borrowed lock is not claimed to close that race, because it does not.proper-lockfile@4.1.2rmdirs a stale lock and re-mkdirs without checking the directory it removed is the one it found stale, so a slow waiter can still delete a fast waiter's fresh lock; what it adds over the hand-rolled version is detection — its refresh tick compares the lock's mtime against the value recorded at acquire, so a compromised holder is told (ECOMPROMISED, surfaced as a warning) instead of proceeding silently. What is exclusive is the case that matters:mkdiris atomic and a live holder refreshes its mtime, so its lock never goes stale and two running Inspectors are genuinely serialized. The residual window opens only after a holder dies without releasing. A waiter also waits past the stale window before giving up (so a crashed holder resolves by takeover rather than failing everyone else's saves), and a lock still held after that makessetfail rather than write alongside a visible concurrent writer —ELOCKEDis evidence the lock is working, not a reason to bypass it. The optimistic verify stays underneath it, and is not redundant: readM0, apply, writeM1, read backM2, re-apply onto whatever a concurrent writer left if they differ, bounded, withsetthrowing on non-convergence rather than returning as though the value were saved. The comparison is over the whole map — checking only your own entry passes in exactly the case that loses data, because yours is present and the other writer's is gone. A lock is advisory between the processes that take it, so the verify is what covers a writer outside this codebase (an editor, a restored backup, an Inspector predating #2082) and what covers the lock being unavailable:withSecretFileLockruns the body anyway, warning once, on a directory that cannot hold a lock file — this store exists for boxes where the usual mechanism is missing (#1848, #1905) and must not acquire a new way to be unavailable. Reads take no lock;writeStoreFileis atomic, so a reader sees the old file or the new one, never a torn one. - Strict reads (
getStrict):getis tolerant by contract, and that tolerance is wrong for exactly one caller — a migration that treatsnullas proof of absence and then writes. A transient read failure would look like "nothing there", and the write would replace a newer stored value with an older on-disk copy, inverting the keychain-wins rule the migration is built on.getStrictthrows instead, and both plaintext migrations plus the keychain hand-off use it. Two traps this hit on the way in, both worth remembering: the seam must be forwarded byDeferredSecretStore(the store production actually uses, so without forwarding the strictness existed only in tests that injected a concrete store), and it must be implemented by every store rather than falling back togetfor the one it was introduced for. - Bulk reads (
getMany): rehydration asked field by field, andFileSecretStore.getreads and decrypts the entire file per call. scrypt atN=16384measures ~23ms, so an encrypted catalog spent ~450ms of pure key derivation on everyGET /api/servers— a visible stall rather than a micro-optimization. The seam takes a list of{ serverId, fields }across servers, and both rehydration callers pass the whole catalog in one request, soFileSecretStoredecrypts once per rehydration; stores for which per-field reads are already cheap (the keychain) fall back to parallelget. The cross-server shape is the point: a per-server version shipped first and left a 20-server catalog paying 20 serialized derivations, because both callers iterate servers — the same stall reached one server at a time instead of one field at a time. - Durability gate on migration: the plaintext-stripping migrations only delete the disk copy once the value is somewhere that outlives the process (
isDurable). Against a session-scoped store, stripping would trade a secret that survives restarts for one that dies with the process — and it runs on an ordinaryGET, so merely opening the app would destroy it. - Hand-off when a keychain appears: install libsecret after storing secrets in a file, and the next run selects the keychain and stops seeing them — still on disk, read by nothing, nothing visibly broken.
absorbFileSecretsIntoKeyringcopies them over on that run, under the same keychain-wins rule, and deletes the file only on complete success. The claim runs under the same cross-process lock asettakes, and the source is claimed atomically within it: the livesecrets.jsonis renamed to a unique snapshot (pid plus a per-attempt nonce, so a staging path can never be reused — pid 1 recurs on every container start), the migration reads only that snapshot, and a writer that recreates the live path is untouched and migrated on the next run. The snapshot is deleted only on a complete hand-off; otherwise it is restored withlink+unlinkrather thanrename, since POSIXrenamesilently replaces its destination and would overwrite a newer live file. A snapshot left behind by a process that died mid-migration is adopted at startup — checking only the canonical path would otherwise report "nothing to migrate" while every stored credential quietly disappeared. - Surfacing it: the active store rides
GET /api/configas asecretStoragedescriptor and is stated in a permanent footer at the bottom of every dialog that accepts a secret: Client Settings (the enterprise IdP client secret), Server Settings (the per-server OAuth client secret and stdioenv:values), and Server Config (stdioenv:values). That third one was missed at first, which made it the one dialog taking secrets with no disclosure at all — so the count here is load-bearing rather than descriptive. A startup banner is seen once by whoever started the process; a toast is seen once; a dismissible banner is by design the thing a user dismisses before doing the work it describes. The descriptor is re-derived per request rather than cached, becauseplaintext/pendingEncryptiondescribe bytes this very process changes.
- Selection:
-
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.