feat(auth): file-backed and in-memory SecretStore for hosts with no keychain

Closes #1950

Before this, a box with no reachable OS keychain — the published
container (no D-Bus session, #1848), Android/Termux (no platform binary,
#1905), a minimal Linux install without libsecret — could not persist an
OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore`
degraded into a store that silently holds nothing: `get` returned null,
`delete` no-opped, and `set` hard-failed with a 503. That is the correct
failure behavior, but it left the documented, supported way to run the
Inspector with no secret persistence whatsoever.

Adds the two missing implementations and, more importantly, the policy
that picks one and the surfaces that say which was picked.

- `FileSecretStore` — one `0600` JSON document at
  `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever
  `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The
  whole map is encrypted as a unit rather than 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. A file that can no longer be decrypted reads as empty
  (matching the interface's read tolerance) but *refuses to be written*,
  because replacing a file of still-valid secrets to satisfy an additive
  request destroys data.

- `secret-store-selection.ts` — the policy.
  `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright;
  otherwise probe the keychain and, if unreachable, fall back loudly. 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 `--rm` will discard. Cached per process so the
  banner, `/api/config`, and the store doing the writing cannot disagree.

- Automatic, but never silent. The downgrade prints at startup, rides
  `GET /api/config`, and is named in a permanent 32px footer at the
  bottom of both 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 the thing a user dismisses before doing the work
  it describes. The plaintext and memory cases render their caveat inline
  in a warning tone — a warning you must hover to discover is not one.

- `SecretStoreUnavailableError` is now the base every store's `set`
  throws, so the routes translate one condition into a 503 instead of
  matching `KeychainUnavailableError` by name.

Docs: the container section of the root README gains a table of which
store you get and how to make secrets survive, plus the
`MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules.

Tests: the existing interface contract block is extracted to
`secretStoreContract.ts` and run against all three implementations, so
none can drift on the availability contract. `FileSecretStore` is tested
against a real temp filesystem — mode, cross-instance round-trip, that
the ciphertext does not contain the plaintext, the read-modify-write
race, and every refusal path. Full `npm run ci` passes, per-file gate
included.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 12:06:31 -04:00
commit bc408f5869

View file

@ -152,6 +152,28 @@ export function parseAccount(
const buildAccount = (serverId: string, field: string): string =>
`${serverId}:${field}`;
/**
* Base type for "the active secret store cannot do this right now".
*
* Every store implementation hard-fails on `set` and only on `set` (see
* the {@link SecretStore} contract), and the API routes translate that
* one condition into a 503. Before #1950 there was only one store, so the
* routes could match on `KeychainUnavailableError` directly; now that a
* file-backed store can fail for entirely different reasons (an
* unreadable file, a changed passphrase) they match on this base instead,
* and a future store gets the same treatment by extending it rather than
* by every route learning its name.
*
* The message is the user-facing text — these are surfaced verbatim in
* the 503 body — so subclasses write advice, not diagnostics.
*/
export class SecretStoreUnavailableError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "SecretStoreUnavailableError";
}
}
/**
* Thrown when the OS keychain is unavailable. Surfaced as a 503 by the
* API handlers so the UI can show an actionable error rather than a
@ -168,7 +190,7 @@ const buildAccount = (serverId: string, field: string): string =>
* - **It loads but exposes the wrong API** — a version or packaging
* mismatch (`KeyringModuleShapeError`). Also not a daemon problem.
*/
export class KeychainUnavailableError extends Error {
export class KeychainUnavailableError extends SecretStoreUnavailableError {
constructor(cause: unknown) {
const message = cause instanceof Error ? cause.message : String(cause);
super(
@ -197,11 +219,86 @@ const hintFor = (cause: unknown, message: string): string => {
return `On Linux, install libsecret / gnome-keyring.`;
};
/**
* Probe whether the OS keychain is actually usable, rather than merely
* importable.
*
* This is the input to the automatic-fallback policy in
* `secret-store-selection.ts`, and it deliberately does more than
* {@link loadKeyring}: on the container that motivated #1848 the package
* imports and shape-checks perfectly well, and the failure only appears
* when `AsyncEntry::new` tries to reach a Secret Service that isn't
* there. So the probe constructs an entry and performs a real read.
*
* A **read** and not a write, on purpose. A write would be the stronger
* signal — a keychain can in principle be readable and not writable — but
* it means depositing a value in the user's login keyring at every
* startup, for a store they may never use, that we would then have to
* clean up (and would leave behind if the process died between the two
* calls). Writing into someone's keychain uninvited to answer a question
* about our own configuration is not a trade worth making; a write that
* fails after a passing probe still surfaces as the documented 503.
*
* Never throws — the whole point is to answer a question, and a probe
* that could fail its caller would just move the crash it exists to
* prevent.
*/
export async function probeKeyringAvailable(): Promise<
{ available: true } | { available: false; detail: string }
> {
const keyring = await loadKeyring();
if (!keyring.ok) {
const err = keyring.err;
return {
available: false,
detail: err instanceof Error ? err.message : String(err),
};
}
try {
const entry = new keyring.mod.AsyncEntry(SERVICE_NAME, PROBE_ACCOUNT);
await entry.getPassword();
return { available: true };
} catch (err) {
return {
available: false,
detail: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Account used by {@link probeKeyringAvailable}. Namespaced under a server
* id that cannot collide with a real one (`:` is the account separator, so
* a real id never contains one) and never written — it only ever appears
* as the argument to a lookup that is expected to miss.
*/
const PROBE_ACCOUNT = "__inspector:probe";
/**
* Storage interface for the per-server secrets we lift off
* `~/.mcp-inspector/mcp.json`. Implemented by `KeyringSecretStore` (the
* production impl) and `InMemorySecretStore` (used in tests so the suite
* doesn't require libsecret in CI).
* `~/.mcp-inspector/mcp.json`.
*
* Three implementations, selected per run by
* `secret-store-selection.ts` (#1950) and described to the user by the
* `SecretStorageInfo` it returns:
*
* - `KeyringSecretStore` — the OS keychain, and the default wherever one
* is reachable.
* - `FileSecretStore` — `~/.mcp-inspector/secrets.json`, `0600`,
* encrypted when `MCP_INSPECTOR_SECRET_KEY` is set. The fallback on a
* host with no keychain, and on a container with a mounted volume.
* - `InMemorySecretStore` — the session-scoped store. Used by the test
* suite (so CI needs no libsecret), and as the container fallback when
* nothing durable is mounted, where it is the honest choice rather than
* the degraded one: a file in the writable layer promises persistence
* the next `docker run` will not honor.
*
* All three share one availability contract, which is what lets callers
* stay ignorant of which they got: `get` is tolerant (`null` on any
* failure), `delete` no-ops, and `set` is the only operation that
* hard-fails — throwing a {@link SecretStoreUnavailableError} the API
* routes turn into a 503 — because it is the only one where a value would
* be lost.
*/
export interface SecretStore {
get(serverId: string, field: string): Promise<string | null>;