feat(servers): move oauthClientSecret and stdio env values to OS keychain (#1356)#1360
feat(servers): move oauthClientSecret and stdio env values to OS keychain (#1356)#1360cliffhall wants to merge 5 commits into
Conversation
…hain (#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>
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>
|
@claude review |
|
Claude finished @cliffhall's task in 3m 24s —— View job Code ReviewSolid, well-documented move of OAuth client secrets and stdio env values into the OS keychain. The SecretStore abstraction is clean, the migration logic is idempotent, and the test coverage is impressive (the 🐛 Concurrency: the GET migration write can clobber concurrent POST/PUT/DELETE
Trace:
Same shape affects the first-launch seed write at The existing write lock around mutating routes was introduced precisely to prevent this class of lost-update ( Fix: put the entire read + migrate + write sequence inside
|
| Severity | Count |
|---|---|
| Bug (concurrency) | 1 |
| Robustness (partial-failure) | 1 |
| Minor / nits | 4 |
The concurrency bug in GET is the only item I'd block on; everything else is nice-to-have. Approve after the read/write-lock fix lands.
· feat/1356-oauth-client-secret-keychain
…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>
|
Thanks for the thorough review. Addressed in 🐛 GET migration race — fixed. The handler now takes a fast unlocked peek; only the seed branch and the migration-needed branch enter
Minor observations:
Full suite green: 143 files, 1743 tests, coverage thresholds met. CI should reflect the same. |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 1s —— View job Re-review ✅All four items from the prior review are addressed correctly in 🐛 The GET race fix (server.ts:1298–1355)Resolved. The structure now reads: The "no plaintext after taking the lock" branch at line 1341 is the detail that makes this airtight: it handles the case where a concurrent POST landed between the peek and the lock acquisition — we no-op cleanly instead of speculatively migrating a stale snapshot. The new
|
| Severity | Count |
|---|---|
| Blocking | 0 |
| Nice-to-have | 2 |
Approve once CI is green — the blocking concurrency fix is in, the partial-failure path is now retry-safe, and the new regression test pins the contract. Nice work on the iteration.
· feat/1356-oauth-client-secret-keychain
…mment (#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>
|
Thanks for the re-review and approve. Picked up both nice-to-haves in
CI should be green. |
|
@claude review |
|
Claude finished @cliffhall's task in 1m 0s —— View job Third review —
|
| Severity | Count |
|---|---|
| Blocking | 0 |
| Nice-to-have | 1 (deleteKeychainFields symmetry) |
LGTM — approve once CI is green. The full arc across the three review rounds (race fix → write-ordering flip → parallelism + partial-state doc) leaves the keychain subsystem in a retry-safe, low-latency, well-documented state. Nice close.
· feat/1356-oauth-client-secret-keychain
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>
|
Thanks for the approve and the symmetry catch. Picked it up in All three keychain helpers (read / write / delete) now have the same shape. |
|
@claude review |
|
Claude finished @cliffhall's task in 1m 17s —— View job Fourth review —
|
| Round | Items | Status |
|---|---|---|
| 1 — initial | GET migration race (blocking), POST/PUT write ordering, 4 nits | All addressed in c2bd6d00 |
| 2 — re-review | 2 nice-to-haves (writeKeychainEntriesFor parallelism, migration partial-state comment) |
Addressed in 18012ae1 |
| 3 — third pass | 1 nice-to-have (deleteKeychainFields symmetry) |
Addressed in 7ed78b52 |
| 4 — this review | 0 | — |
LGTM — approve once CI is green. The full arc (race fix → write-ordering flip → parallel write + partial-state doc → parallel delete) leaves the keychain subsystem retry-safe, low-latency, symmetrical across read/write/delete, and well-documented in both code and the spec. Nice close.
· feat/1356-oauth-client-secret-keychain
Summary
Closes #1356.
mcp.jsonis designed to be tool-shareable (symlinked from Claude Desktop's config, pasted into bug reports, synced via dotfiles). Storing OAuth client secrets and stdio env values in plaintext there meant any of those flows could leak them. This PR lifts both into the OS keychain.@napi-rs/keyring(active replacement for the archivedkeytar). Servicemcp-inspector; account${serverId}:${field}with fieldsoauth-client-secretandenv:${KEY}.oauth.clientSecretremoved entirely; stdioenvkeys preserved with empty-string placeholders so the file still documents the env interface the server expects.GET /api/serversrehydrates values from the keychain so the browser sees the same JSON it has always seen. The OAuth handshake still runs in the browser via the MCP SDK; moving the token exchange to Node is a separate piece of work and explicitly out of scope.mcp.jsonwith the stripped shape. If the keychain already holds a value for(serverId, field), the keychain wins and the disk plaintext is dropped unread.KeychainUnavailableError; handlers translate to a503with a message pointing at libsecret/gnome-keyring install. macOS and Windows always have a working keychain.SecretStoreis an interface with anInMemorySecretStoretest double that the integration suite injects viaRemoteServerOptions.secretStore, so the suite doesn't need libsecret on Linux CI.Files
core/auth/secret-fields.ts(new) — shared field-name constants (browser-safe).core/auth/node/secret-store.ts(new) —SecretStoreinterface +KeyringSecretStore(production) +InMemorySecretStore(test) +KeychainUnavailableError.core/mcp/serverList.ts— pure helpersextractSecretsFromStored/mergeSecretsIntoStored/expectedSecretFields.core/mcp/remote/node/server.ts— keychain hooks across GET (rehydrate + migration), POST (strip + write), PUT (reconcile / rename), DELETE (sweep).specification/v2_servers_file.md— documents the keychain split, migration, and Linux fallback behavior.Test plan
npm run validatefromclients/web/— passes (format / lint / build / coverage).npm run test:storybook— 306/306 pass.serverList.test.ts) — 14 added.InMemorySecretStorecontract (secret-store.test.ts) — 10 added./api/serversroute (servers-route.test.ts) — 9 added, total file now 50/50.mcp.jsoncontains no plaintext and Keychain Access on macOS shows the entry under servicemcp-inspector.mcp.jsonto put a plaintext secret back, hit refresh, confirm the file is rewritten with the value stripped.🤖 Generated with Claude Code