fix(auth): address Copilot review round 10, and document the concurrency design

The headline is a correction to my own claim rather than a new defect.

**The optimistic verify is weaker than the comment said it was.** It
described the residual as a crash between the write and the verifying read.
The reviewer pointed out that no crash is needed: order two writers as
write-A, verify-A, write-B, verify-B and both report success while A's entry
is gone — A's verify simply ran before there was anything to see, and B did
nothing wrong. A crash is one instance of that shape, not the whole of it.
The trace in the old comment quietly assumed A verifies *after* B writes,
which nothing guarantees.

Corrected in the doc comment, the README and the spec, and the honest
summary is now stated rather than implied: this converges in every
interleaving where the clobber lands before the verify, it is not mutual
exclusion, and a deployment that needs the guarantee wants an OS-backed lock
from a dedicated library — not another hand-rolled election. It remains
better than what it replaced, which lost updates across a wider set of
interleavings and could not be closed at all with what Node exposes.

The other findings:

- **An envelope naming the cipher still is not an openable one.** Round 9
  added a `kdf`/`data` presence check; three dot-separated strings satisfied
  it while an empty tag, a 4-byte IV or `N: 3` still made every decrypt
  throw — so the quiet "Encrypted file" footer went to a file whose next
  save was guaranteed to fail. Now validates part sizes, a non-empty
  ciphertext, and the scrypt cost parameters (node rejects a non-power-of-two
  `N` synchronously, out of the derivation rather than the decrypt). A
  positive control asserts a file we actually wrote still reads as encrypted.

- **`encryptionUnknown` + `looseMode` claimed an exposure it had not
  established.** With both set, `plaintext` is absent, so a `!== false` test
  landed on "anyone who can read it can read the secrets in it" — about a
  file that may well hold ciphertext we simply could not classify. Three-way
  now, in `secretStorageCaveat` and mirrored in the footer's tooltip, with a
  test on each.

- **The README described a lock that no longer exists**, which is the worst
  kind of stale doc: it stated a durability guarantee the code had stopped
  providing. Rewritten to describe the actual design, including the residual.

- **The private-method access in the concurrency test** now carries the
  justification the repo's TypeScript rules require: the asserted shape is
  `writeMap`'s real signature, so a rename breaks the line instead of
  silently detaching the stub, and a production injection seam would let the
  real write path drift from the tested one.

Also updates `specification/v2_servers_file.md`, which still described #1356's
world: one keychain-only store, and unavailability as a hard error. It now
covers store selection and the probe, the file format and lazy encryption
upgrade, the concurrency design and its residual, the `getStrict`/`getMany`
seams and the two traps `getStrict` hit, the durability gate on migration,
the keychain hand-off and its conditional delete, and how the active store is
surfaced.

Full `npm run ci` green. (One unrelated `AppRenderer` theme-flip test failed
once under full-suite parallelism and passes in isolation with and without
these changes — a flake, not a regression.)

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 18:33:59 -04:00
commit 219765efff
8 changed files with 267 additions and 72 deletions

View file

@ -482,7 +482,9 @@ Setting the passphrase later is safe — the next write upgrades an existing pla
The Inspector writes the file `0600` and re-tightens it at startup if something loosened it. If it _cannot_ — the file belongs to another user, or the mount is read-only — it says so in the log rather than continuing to describe the file as protected, since on that box the mode claim above is not true.
**One durable file, one writer.** Mutations take a lock (`secrets.json.lock`) so a second Inspector on the same box — a CLI run beside a web session — cannot read the same state and overwrite the first one's save. A lock left behind by a killed process is treated as abandoned after 10 seconds and stolen, because a lock that only a clean exit can release would turn one `docker kill` into permanently broken secret writes.
**Two Inspectors, one file.** Within a process, mutations are serialized per file path, so a web session's own concurrent saves cannot lose each other. Across processes — a CLI run beside a web session — there is deliberately **no lock**: writers are allowed to collide and the loser is made to notice. Each mutation reads the file, applies its change, writes, then reads back and compares the whole map; if another process wrote in between, it re-applies onto what they left and retries, and after five lost rounds it fails loudly rather than returning as though the value were saved.
This is **not mutual exclusion**, and the residual case is worth stating: the verify only catches a clobber that has already landed, so if one Inspector reads back *before* the other's write arrives, both report success and one value is gone. That needs two Inspectors writing the same file within the gap between one's write and its read-back — narrow, but not only a crash. An earlier build did take a lock (`secrets.json.lock`); it was removed because making a `mkdir` lock single-winner on a stale takeover needs a compare-and-swap on a directory entry that Node does not expose, so it had the same class of failure with several hundred more lines and no way to close it.
Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there.

View file

@ -106,6 +106,36 @@ describe("SecretStorageFooter", () => {
expect(band).toHaveAttribute("data-tone", "warn");
});
it("does not claim exposure in the tooltip when encryption is unknown", async () => {
// Mirrors `secretStorageCaveat`: `plaintext` is absent alongside
// `encryptionUnknown`, and a two-way `!== false` test would assert that
// a reader gets the secrets out of a file we could not even classify.
// The claim lives in the *tooltip*, so that is what this hovers to read
// — the visible line reports the unreadable envelope, which is a
// different (and also correct) statement.
const user = userEvent.setup();
renderWithMantine(
<SecretStorageFooter
info={{
...plaintextFile,
plaintext: undefined,
looseMode: 0o644,
encryptionUnknown: "not valid JSON",
}}
/>,
);
const band = screen.getByTestId("secret-storage-footer");
expect(band).toHaveAttribute("data-tone", "warn");
// The tooltip's target is the copy button, not the band around it.
await user.hover(screen.getByRole("button", { name: /Copy secrets file/ }));
const tip = await screen.findByText(/Mode 0644 could not be tightened/);
expect(tip).toHaveTextContent("could not determine");
expect(tip).not.toHaveTextContent(
"anyone who can read the file can read the secrets in it",
);
});
it("renders nothing when the backend didn't report a store", () => {
// A guessed answer under a secret field is worse than no answer, so the
// unknown case is silence rather than a default.

View file

@ -197,9 +197,16 @@ function footerTooltip(info: SecretStorageInfo): string | undefined {
// secrets. Claiming otherwise overstates the exposure, and a warning
// that cries wolf is discounted on the occasion it is literally true.
parts.push(
info.plaintext === false
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.`
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
// Three cases, mirroring `secretStorageCaveat`. With
// `encryptionUnknown` set, `plaintext` is absent and `!== false` would
// land on the "anyone can read the secrets" wording — asserting an
// exposure we did not establish, since the envelope may hold
// ciphertext we simply could not classify.
info.encryptionUnknown !== undefined
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and whether that exposes the secrets depends on encryption this build could not determine (${info.encryptionUnknown}).`
: info.plaintext === false
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.`
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
);
} else if (info.permissionsUnknown !== undefined) {
parts.push(

View file

@ -113,6 +113,27 @@ describe("secretStorageCaveat", () => {
);
});
it("does not claim exposure when the encryption state is unknown", () => {
// `looseMode` with `encryptionUnknown` leaves `plaintext` absent, and a
// `!== false` test then lands on the plaintext wording — asserting that
// a reader gets the secrets. The envelope may hold ciphertext; we could
// not classify it. Claiming the worse of two unknowns is still claiming
// something we never established.
const loose: SecretStorageInfo = {
kind: "file",
reason: "fallback",
durable: true,
looseMode: 0o644,
encryptionUnknown: "not valid JSON",
path: "/tmp/secrets.json",
};
const caveat = secretStorageCaveat(loose);
expect(secretStorageTone(loose)).toBe("warn");
expect(caveat).toContain("0644");
expect(caveat).toContain("could not determine");
expect(caveat).not.toContain("anyone who can read it can read the secrets");
});
it("warns about a loose mode even on an encrypted file", () => {
// The passphrase is then the only thing between a reader and the values,
// which is worth saying rather than rendering the quiet neutral state.

View file

@ -936,6 +936,50 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => {
},
"encrypted payload is not iv.tag.ciphertext",
],
// Three dot-separated strings are not enough: each of these satisfies a
// part-count check and still makes every decrypt throw, so reporting
// "encrypted" would hand the quiet footer to a file whose next save is
// guaranteed to fail.
[
"an initialization vector of the wrong length",
{
version: 1,
encryption: "aes-256-gcm",
kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 },
data: `${Buffer.alloc(4).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
},
"initialization vector is 4 bytes, expected 12",
],
[
"an authentication tag of the wrong length",
{
version: 1,
encryption: "aes-256-gcm",
kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 },
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(3).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
},
"authentication tag is 3 bytes, expected 16",
],
[
"a KDF cost parameter node would reject",
{
version: 1,
encryption: "aes-256-gcm",
kdf: { algorithm: "scrypt", salt: "AAAA", N: 3, r: 8, p: 1 },
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
},
"kdf N is 3, which is not a power of two above 1",
],
[
"an unsupported KDF",
{
version: 1,
encryption: "aes-256-gcm",
kdf: { algorithm: "pbkdf2", salt: "AAAA", N: 16384, r: 8, p: 1 },
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
},
'unsupported kdf "pbkdf2"',
],
])("reports unreadable for %s", async (_label, envelope, detail) => {
await fs.writeFile(filePath(), JSON.stringify(envelope), "utf-8");
const store = new FileSecretStore({ filePath: filePath() });
@ -946,6 +990,19 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => {
});
});
describe("readOnDiskEncryption accepts a real envelope", () => {
it("still reports encrypted for a file this build actually wrote", async () => {
// The negative cases above are only meaningful next to this: tightening
// the validation must not start rejecting the files we produce.
const store = new FileSecretStore({
filePath: filePath(),
passphrase: "hunter2",
});
await store.set("srv", "env:A", "1");
expect(await store.readOnDiskEncryption()).toEqual({ state: "encrypted" });
});
});
describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
// The lock these replace was removed after three review rounds each found
// a real race in it; the last needed a compare-and-swap on a directory
@ -1017,6 +1074,15 @@ describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
// queue and writing through one from inside a queued section would
// deadlock on it.
const clobberAfterEveryWrite = (store: FileSecretStore): void => {
// Double cast, justified per the repo's TypeScript rules. The property
// reached is `private`, so no single cast expresses it. What makes it
// safe is that the asserted shape is `writeMap`'s real signature: the
// replacement is checked against it, so a rename or a signature change
// fails this line rather than silently leaving the stub detached and the
// test passing for the wrong reason. The alternative — a production
// injection seam — would add API surface existing only for this test,
// and would let the real write path drift from the tested one, which is
// precisely what this test exists to catch.
const inner = store as unknown as {
writeMap: (m: Record<string, string>) => Promise<void>;
};

View file

@ -94,6 +94,8 @@ const FORMAT_VERSION = 1;
const CIPHER = "aes-256-gcm";
const KEY_BYTES = 32;
const IV_BYTES = 12;
/** AES-GCM authentication tag length, in bytes. */
const GCM_TAG_BYTES = 16;
/** scrypt defaults. `N` is the only one that meaningfully costs time; 2^14
* derives in a few milliseconds, which is invisible next to the file I/O it
* accompanies and is derived once per read/write rather than per secret. */
@ -235,6 +237,48 @@ export type SecretFileEncryptionState =
| { state: "encrypted" }
| { state: "unreadable"; detail: string };
/** A positive, finite integer? */
const isPositiveInt = (v: unknown): v is number =>
typeof v === "number" && Number.isInteger(v) && v > 0;
/**
* Why this encrypted envelope could not be opened, or `null` if it can.
*
* Everything `readMap` needs before it reaches `createDecipheriv`, checked
* in the same order it would fail: the parts, then their sizes, then the
* derivation parameters. `scrypt` validates its own cost parameters
* *synchronously*, so `N: 3` throws out of the derivation rather than
* arriving as a decrypt failure — which is why they are checked here rather
* than left to the cipher.
*/
function encryptedEnvelopeProblem(parsed: SecretFile): string | null {
if (!parsed.kdf || typeof parsed.data !== "string") {
return "encrypted envelope is missing its kdf or data";
}
const parts = decodeParts(parsed.data);
if (!parts) return "encrypted payload is not iv.tag.ciphertext";
if (parts.iv.length !== IV_BYTES) {
return `initialization vector is ${parts.iv.length} bytes, expected ${IV_BYTES}`;
}
if (parts.tag.length !== GCM_TAG_BYTES) {
return `authentication tag is ${parts.tag.length} bytes, expected ${GCM_TAG_BYTES}`;
}
if (parts.body.length === 0) return "ciphertext is empty";
const { algorithm, salt, N, r, p } = parsed.kdf;
if (algorithm !== "scrypt") return `unsupported kdf "${String(algorithm)}"`;
if (typeof salt !== "string" || Buffer.from(salt, "base64").length === 0) {
return "kdf salt is missing or not base64";
}
// `N` must be a power of two greater than 1; node rejects anything else.
if (!isPositiveInt(N) || N < 2 || (N & (N - 1)) !== 0) {
return `kdf N is ${String(N)}, which is not a power of two above 1`;
}
if (!isPositiveInt(r) || !isPositiveInt(p)) {
return `kdf r/p are ${String(r)}/${String(p)}, which are not positive integers`;
}
return null;
}
export interface FileSecretStoreOptions {
/** Absolute path of the secrets file. */
filePath: string;
@ -313,23 +357,14 @@ export class FileSecretStore implements SecretStore {
}
if (parsed.encryption === "none") return { state: "plaintext" };
if (parsed.encryption === CIPHER) {
// Naming the cipher is not the same as being openable. An envelope
// with no `kdf`/`data` — or a `data` that is not the three-part
// payload — is refused by `readMap`, so reporting it as "encrypted"
// gives the quiet, reassuring footer for a file whose very next save
// is guaranteed to fail. Check the shape the decryptor requires.
if (!parsed.kdf || typeof parsed.data !== "string") {
return {
state: "unreadable",
detail: "encrypted envelope is missing its kdf or data",
};
}
if (!decodeParts(parsed.data)) {
return {
state: "unreadable",
detail: "encrypted payload is not iv.tag.ciphertext",
};
}
// Naming the cipher is not the same as being openable, and a partial
// check is its own trap: three dot-separated strings satisfy
// `decodeParts` while an empty tag, a 4-byte IV or `N: 3` still make
// every decrypt throw. Reporting "encrypted" then gives the quiet,
// reassuring footer to a file whose very next save is guaranteed to
// fail, which is the one thing this state exists to prevent.
const problem = encryptedEnvelopeProblem(parsed);
if (problem) return { state: "unreadable", detail: problem };
return { state: "encrypted" };
}
return {
@ -509,23 +544,32 @@ export class FileSecretStore implements SecretStore {
* someone wrote between our write and our read — so re-apply onto what
* they left and try again.
*
* The comparison is over the **whole map**, not just the entry we touched,
* and that is the entire reason this works. Checking only our own key
* passes in precisely the case that loses data: our entry is present, and
* it is the *other* writer's that is gone. Trace two writers A and B from
* the same `M0` — A writes `MA`, B writes `MB` and clobbers A's entry. B
* verifies and sees `M2 === MB`, correctly: B did nothing wrong. A
* verifies, sees `M2 !== MA`, re-applies onto `MB`, and writes again. Both
* entries survive, and the writer that was clobbered is the one that
* repairs it.
* The comparison is over the **whole map**, not just the entry we touched.
* Checking only our own key would pass in precisely the case that loses
* data: our entry is present, and it is the *other* writer's that is gone.
* When the verify does fire, the writer that was clobbered is the one that
* repairs it — A writes `MA`, B writes `MB` over it, B verifies `MB`
* correctly, A verifies, sees `M2 !== MA`, re-applies onto `MB`.
*
* **The residual window, stated plainly rather than left to be
* rediscovered:** a process that crashes between its write and its
* verifying read cannot notice it was clobbered, and nothing else will.
* That is strictly narrower than the lock's failure — which lost updates
* with every participant alive and well — and unlike the lock it needs no
* primitive Node lacks. It is not mutual exclusion and this file does not
* claim to provide it.
* **This is not mutual exclusion, and the gap is wider than a crash.**
* The verify only catches a clobber that has already landed. Order the
* same two writers as write-A, verify-A, write-B, verify-B and both
* succeed while A's entry is gone: A's verify ran before B's write, so
* there was nothing yet to see, and B did nothing wrong. Nothing detects
* it afterwards. A crash between write and verify is one instance of the
* same shape, not the whole of it — an earlier version of this comment
* said otherwise, which understated it.
*
* What that buys, stated without overselling: two processes must write the
* same file within the window between one's write and its read-back, and
* the loss is one secret that reported success. The lock this replaced
* lost updates in a *wider* set of interleavings, with every participant
* alive, and could not be closed without a primitive Node does not expose
* (`renameat2(RENAME_EXCHANGE)`); this costs ~220 fewer lines and converges
* in every interleaving where the clobber lands before the verify. If the
* residual matters for a deployment, the answer is a real lock — an
* OS-backed one from a dedicated library — not another hand-rolled
* election.
*
* Reads deliberately do not participate: `writeStoreFile` is atomic
* (write-temp-then-rename), so a reader sees either the old file or the

View file

@ -169,6 +169,15 @@ export function secretStorageCaveat(
// kind of wrong: a reader of an *encrypted* file gets ciphertext, not
// secrets. Saying otherwise trains people to discount the warning on the
// occasion it is literally true.
// Three cases, not two. With `encryptionUnknown` set, `plaintext` is
// absent — and `!== false` then lands on the plaintext wording, which
// asserts that a reader gets the secrets. The envelope may well hold
// ciphertext; we simply could not tell. Claiming the worse of two
// unknowns is still claiming something we did not establish, which is
// the failure this descriptor exists to avoid.
if (info.encryptionUnknown !== undefined) {
return `The secrets file is mode ${mode}, not 0600, and could not be tightened — others can copy it, and whether that exposes the secrets depends on encryption this build could not determine (${info.encryptionUnknown}).`;
}
return info.plaintext === false
? `The secrets file is mode ${mode}, not 0600, and could not be tightened — others can copy it, and the passphrase is then the only thing protecting its contents.`
: `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`;

View file

@ -2,7 +2,8 @@
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md)
#### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | Storage
#### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | Storage
##### [Overview](v2_storage.md) | Server List File
## Summary
@ -38,12 +39,12 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil
"filesystem-server-default": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
"everything-server-default": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
"args": ["-y", "@modelcontextprotocol/server-everything"],
},
"acme-api": {
"type": "streamable-http",
@ -54,16 +55,16 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil
// 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" },
"headers": { "X-Tenant": "acme" },
"metadata": [{ "key": "trace", "value": "abc" }],
"connectionTimeout": 30000,
"requestTimeout": 60000,
"requestTimeout": 60000,
"oauth": {
"clientId": "client-abc",
"scopes": "read:tools write:tools"
}
}
}
"scopes": "read:tools write:tools",
},
},
},
}
```
@ -81,12 +82,12 @@ If the file does not exist when the backend boots, write a file containing the t
### 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>` |
| 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`
@ -123,16 +124,17 @@ Also re-export `normalizeServerType` from `core/mcp/node/config.ts` (or move it
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) |
| 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:
```ts
/** Optional path for the user's server list file. Default: ~/.mcp-inspector/mcp.json */
mcpConfigPath?: string;
@ -149,12 +151,16 @@ export interface UseServersResult {
error: string | undefined;
refresh: () => Promise<void>;
addServer: (id: string, config: MCPServerConfig) => Promise<void>;
updateServer: (originalId: string, newId: 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
baseUrl: string; // window.location.origin by default in callers
authToken: string | undefined;
}): UseServersResult;
```
@ -171,7 +177,7 @@ Fetches on mount via `fetch(`${baseUrl}/api/servers`, ...)` with the auth header
### 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).
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.
@ -199,7 +205,7 @@ Per `AGENTS.md`'s "test new or modified code" rule plus the UI-changes guidance:
## 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.
- **Concurrent writes from multiple browser tabs.** Granular endpoints reduce the surface (per-row, not whole-file). Same-row contention on `mcp.json` is last-write-wins, which is fine for a config the user is editing manually. We do _not_ lock `mcp.json`; the cost outweighs the rare case. The _secrets_ file is a separate question with a separate answer — see [Secret storage](#secret-storage-1356) — 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 `refresh` returned by the hook). Acceptable for v1; auto-watching the file is a possible follow-up but `fs.watch` semantics across OSes are a long tail of bugs.
- **Schema drift with Claude Desktop / Cursor.** They occasionally add fields (e.g. Claude Desktop's `disabled`). `loadMcpServersConfig` currently does `JSON.parse(...) as MCPConfig` — extra fields survive the round-trip as long as we don't filter them. The converters in `serverList.ts` should preserve unknown fields on `MCPServerConfig` rather 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-memory `useState` list today, so nothing to migrate.
@ -218,17 +224,17 @@ Each server entry may carry these Inspector-extension fields at the top level:
"my-server": {
"type": "streamable-http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" },
"headers": { "Authorization": "Bearer xxx" },
"metadata": [{ "key": "tenant", "value": "acme" }],
"connectionTimeout": 30000,
"requestTimeout": 60000,
"requestTimeout": 60000,
"oauth": {
"clientId": "...",
"clientId": "...",
"clientSecret": "...",
"scopes": "read:tools write:tools"
}
}
}
"scopes": "read:tools write:tools",
},
},
},
}
```
@ -242,14 +248,24 @@ Each server entry may carry these Inspector-extension fields at the top level:
- `requestTimeout` → `InspectorClientOptions.timeout`.
- `connectionTimeout` → `Promise.race` wrapper around `InspectorClient.connect()` in the web client.
- `oauth.clientId` / `oauth.clientSecret` / `oauth.scopes` → pre-seeded OAuth client credentials via `InspectorClientOptions.oauth` (the disk-side `oauth` object is lifted into the flat `oauthClientId` / etc. fields on `InspectorServerSettings` for 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 `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`.
- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values 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 name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is 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 by `GET /api/servers` is 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 shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.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 web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface; `KeyringSecretStore` is the production impl and `InMemorySecretStore` is the test double the integration suite injects via `RemoteServerOptions.secretStore`.
- **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 `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`.
- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values 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 name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is 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 by `GET /api/servers` is 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 shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.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 web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface. As of #1950 there are **three** implementations — `KeyringSecretStore`, `FileSecretStore`, `InMemorySecretStore` — chosen per run by `core/auth/node/secret-store-selection.ts`; the integration suite still injects one directly via `RemoteServerOptions.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**: `KeyringSecretStore` is *tolerant* — only the `set` operation throws `KeychainUnavailableError` (translated to a `503` by the handlers); `get` returns `null` and 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.
- **Linux without libsecret**: every `SecretStore` shares one _tolerance contract_ — `get` returns `null`, the destructive operations silently no-op, and only `set` hard-fails (with a `SecretStoreUnavailableError`, translated to a `503`). 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)](#secret-store-selection-1950).
- **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 `set` failure (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 at `409`, and no premature deletion of an obsolete field whose disk write later fails.
- **Id rename (`PUT` with new `id`)**: secrets are keyed by **server id** (the `mcpServers` map 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, then `deleteAllForServer(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 by `servers-route.test.ts` (`PUT rename moves … when it exists only in the keychain` for 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.json` and is no longer the source-of-truth on the filesystem. Moving the token exchange to the Node side is tracked separately.
- **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 — `set` hard-failed, so those users could not persist an OAuth client secret or a stdio `env:` 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|memory` wins outright. Otherwise the keychain is _probed_ — an `AsyncEntry` construction 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 is **`memory`** in a container whose secrets directory is not on a mount, and **`file`** otherwise — 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 file `docker run --rm` will discard. Resolution is cached per process so the startup banner, `GET /api/config`, and the store doing the writing cannot disagree.
- **`FileSecretStore` at rest**: one JSON document at `~/.mcp-inspector/secrets.json` (or `MCP_INSPECTOR_SECRET_FILE`, else under `MCP_STORAGE_DIR`), written `0600` and re-tightened at startup. With `MCP_INSPECTOR_SECRET_KEY` set 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 reports `pendingEncryption` until 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, and what it does not promise**: within a process, mutations are serialized per **resolved file path** (not per store instance — two `FileSecretStore`s on one file are ordinary, since the resolved store holds one and the keychain hand-off builds another). Across processes there is **no lock**. An earlier iteration had a `mkdir` election with an owner stamp, heartbeat and stale-takeover; three review rounds each found a real race, and the last is not closable with what Node exposes — claiming a stale lock needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`). It was replaced with optimistic concurrency: read `M0`, apply, write `M1`, read back `M2`, and re-apply onto whatever a concurrent writer left if they differ, bounded, with `set` throwing 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. **This is not mutual exclusion.** The verify only catches a clobber that has already landed, so ordering the two writers write-A / verify-A / write-B / verify-B leaves both reporting success with A's entry gone — A's verify ran before there was anything to see. A crash between write and verify is one instance of that shape, not the whole of it. The window is narrower than the lock's (which lost updates across a wider set of interleavings, with every participant alive) and needs no primitive Node lacks, but it is a real residual and is documented as one. If a deployment needs the guarantee, the answer is an OS-backed lock from a dedicated library, not another hand-rolled election.
- **Strict reads (`getStrict`)**: `get` is tolerant by contract, and that tolerance is wrong for exactly one caller — a migration that treats `null` as 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. `getStrict` throws 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 by `DeferredSecretStore` (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 to `get` for the one it was introduced for.
- **Bulk reads (`getMany`)**: rehydration asks field by field, and `FileSecretStore.get` reads and decrypts the entire file per call. scrypt at `N=16384` measures ~23ms, so a 20-secret encrypted catalog spent ~450ms of pure key derivation on every `GET /api/servers` — a visible stall rather than a micro-optimization. `getMany` decrypts once per server; stores for which per-field reads are already cheap fall back to parallel `get`.
- **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 ordinary `GET`, 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. `absorbFileSecretsIntoKeyring` copies them over on that run, under the same keychain-wins rule, and deletes the file **only** on complete success. Since the store takes no lock, the delete is conditional: the file is re-read immediately beforehand and compared against what was copied, and the delete is declined when they differ, because another process may have added an entry that was never copied.
- **Surfacing it**: the active store rides `GET /api/config` as a `secretStorage` descriptor and is stated in a permanent footer at the bottom of the Client Settings and Server Settings modals — the only two dialogs that accept a secret. 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, because `plaintext`/`pendingEncryption` describe bytes this very process changes.
- **Hard-cutover legacy behavior (per #1358 decision 4)**: files written by the one pre-#1358 build of v2/main have a nested `settings` block. `normalizeMcpServers` drops 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**: `ServerSettingsModal` is opened from the server card's settings affordance. Saving routes through `useServers.updateServerSettings(id, settings)` which issues a settings-only `PUT /api/servers/:id` with `{ 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 `onSettingsChange` on every keystroke. `App.tsx` debounces 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/notifications` toast surfaces the failure — the modal has already closed so a silent failure would leave the user thinking the last edits saved.