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
3 changed files with 2827 additions and 146 deletions

View file

@ -0,0 +1,400 @@
/**
* File-backed {@link SecretStore} — the persistence answer for a box with
* no reachable OS keychain (#1950): the published container (no D-Bus
* session, #1848), Android/Termux (no platform binary, #1905), and a
* minimal Linux install without libsecret.
*
* **This partially reverses #1356 on purpose.** That change moved
* `oauthClientSecret` *out of* `mcp.json` and into the keychain to get it
* off disk. Writing secrets back to disk here is therefore a decision, not
* an oversight, and it is bounded three ways: the file is separate from
* `mcp.json` (so a user pasting their catalog into an issue does not paste
* their secrets), it is written `0600`, and it is encrypted whenever
* `MCP_INSPECTOR_SECRET_KEY` is set. What it buys is the alternative:
* before this, those users could not persist a secret at all — `set` threw
* and the route answered 503.
*
* **Format.** One JSON document, whose whole secret map is encrypted as a
* unit rather than value-by-value:
*
* ```json
* { "version": 1, "encryption": "none", "secrets": { "srv:field": "s3cr3t" } }
* { "version": 1, "encryption": "aes-256-gcm",
* "kdf": { "algorithm": "scrypt", "salt": "…", "N": 16384, "r": 8, "p": 1 },
* "data": "<iv>.<authTag>.<ciphertext>" }
* ```
*
* Encrypting the map as a unit — not each value — is what hides the
* *account names* too. Those are `${serverId}:${field}`, so a per-value
* scheme would leave a readable index of which servers you use and which
* of them you hold an OAuth client secret for. That index is worth
* roughly as much to an attacker as some of the values.
*
* **Key.** `MCP_INSPECTOR_SECRET_KEY` is a passphrase, not a key: it is
* stretched with scrypt against a per-file random salt stored beside the
* ciphertext. That means a short, memorable value is safe to use, and it
* means the same passphrase produces a different key for a different file.
*
* **Availability contract — identical to `KeyringSecretStore`'s**, because
* callers must not have to know which store they got. `get` is tolerant
* (returns `null` on any failure), `delete` no-ops, and `set` is the only
* operation that hard-fails, throwing {@link SecretStoreUnavailableError}
* — the moment where a value would actually be lost. Routes translate that
* to a 503 the same way they already do for the keychain.
*
* The one failure worth calling out is the *key mismatch*: a file written
* encrypted, opened later with the passphrase changed or removed. Reads go
* quiet (`null`, per the contract) but writes must not — writing would
* replace a file full of still-valid secrets with one holding a single new
* one, destroying data to satisfy a request that was only ever additive.
* So `set` refuses and says which of the two things to fix.
*/
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import { readStoreFile, writeStoreFile } from "../../storage/store-io.js";
import {
SecretStoreUnavailableError,
type SecretStore,
} from "./secret-store.js";
/**
* Promisified `scrypt`. Hand-wrapped rather than `promisify`d because the
* overload carrying the cost parameters is not the one `promisify`'s types
* pick up, and the alternative — `scryptSync` — blocks the event loop for
* the duration of the derivation on every read and write.
*/
const scrypt = (
passphrase: string,
salt: Buffer,
keylen: number,
options: crypto.ScryptOptions,
): Promise<Buffer> =>
new Promise((resolve, reject) => {
crypto.scrypt(passphrase, salt, keylen, options, (err, key) => {
/* v8 ignore next -- @preserve: node validates the cost parameters
synchronously (an out-of-grammar `N` throws from the call itself,
which the corrupt-KDF test exercises), so this asynchronous channel
is left for allocation failure — not provokable deterministically. */
if (err) reject(err);
else resolve(key);
});
});
const FORMAT_VERSION = 1;
const CIPHER = "aes-256-gcm";
const KEY_BYTES = 32;
const IV_BYTES = 12;
/** 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. */
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
/** Env var holding the passphrase. Absent → the file is written in the clear. */
export const SECRET_KEY_ENV = "MCP_INSPECTOR_SECRET_KEY";
interface KdfParams {
algorithm: "scrypt";
salt: string;
N: number;
r: number;
p: number;
}
interface SecretFile {
version: number;
encryption: "none" | "aes-256-gcm";
kdf?: KdfParams;
secrets?: Record<string, string>;
data?: string;
}
const buildAccount = (serverId: string, field: string): string =>
`${serverId}:${field}`;
/**
* Thrown when a file was written encrypted and the current passphrase
* cannot open it. A distinct type so `set` can refuse with advice that
* fits, instead of the generic "could not write" that would send someone
* looking at permissions.
*/
export class SecretFileKeyMismatchError extends SecretStoreUnavailableError {
constructor(filePath: string, hasKey: boolean) {
super(
hasKey
? `The secrets file at ${filePath} could not be decrypted with the current ${SECRET_KEY_ENV}. Refusing to write, which would overwrite the existing secrets. Restore the original passphrase, or delete the file to start over.`
: `The secrets file at ${filePath} is encrypted but ${SECRET_KEY_ENV} is not set. Refusing to write, which would overwrite the existing secrets. Set the passphrase this file was written with, or delete the file to start over.`,
);
this.name = "SecretFileKeyMismatchError";
}
}
const encodeParts = (iv: Buffer, tag: Buffer, body: Buffer): string =>
`${iv.toString("base64")}.${tag.toString("base64")}.${body.toString("base64")}`;
/**
* Split a `<iv>.<tag>.<ciphertext>` payload, rejecting anything that is not
* exactly three parts. `split(".")` on a truncated or extended payload
* otherwise yields buffers of the wrong length, and `createDecipheriv`
* reports that as a generic error further away from the cause.
*/
function decodeParts(
payload: string,
): { iv: Buffer; tag: Buffer; body: Buffer } | null {
const parts = payload.split(".");
if (parts.length !== 3) return null;
const [iv, tag, body] = parts as [string, string, string];
return {
iv: Buffer.from(iv, "base64"),
tag: Buffer.from(tag, "base64"),
body: Buffer.from(body, "base64"),
};
}
export interface FileSecretStoreOptions {
/** Absolute path of the secrets file. */
filePath: string;
/**
* Passphrase. Defaults to `process.env[SECRET_KEY_ENV]`; an empty or
* whitespace-only value counts as absent, since `MCP_INSPECTOR_SECRET_KEY=`
* in a compose file is a user who meant "off", not a one-character key.
*/
passphrase?: string;
}
export class FileSecretStore implements SecretStore {
readonly filePath: string;
private readonly passphrase: string | undefined;
/**
* Serializes read-modify-write cycles within this process. `writeStoreFile`
* already makes each individual write atomic and ordered, but that is not
* enough here: two concurrent `set`s would each read the same "before" map
* and the second write would drop the first one's entry. The route handlers
* write a server's fields with `Promise.all`, so this is the ordinary path,
* not a race that needs contriving.
*/
private queue: Promise<unknown> = Promise.resolve();
constructor(options: FileSecretStoreOptions) {
this.filePath = options.filePath;
const raw = options.passphrase ?? process.env[SECRET_KEY_ENV];
this.passphrase = raw && raw.trim() ? raw : undefined;
}
/** True when this store encrypts. Drives the `plaintext` descriptor flag. */
get encrypted(): boolean {
return this.passphrase !== undefined;
}
private async deriveKey(kdf: KdfParams): Promise<Buffer> {
return scrypt(
// Only reached when the passphrase is set — every caller checks first,
// which is what makes the non-null assertion safe here rather than a
// widened `string | undefined` threaded through the crypto helpers.
this.passphrase as string,
Buffer.from(kdf.salt, "base64"),
KEY_BYTES,
{ N: kdf.N, r: kdf.r, p: kdf.p },
);
}
/**
* Read and decode the file into a plain map.
*
* Distinguishes three outcomes that callers treat differently: `null` for
* "no file yet" (a first write should create one), a map for success, and a
* throw for "there is a file and we cannot read it" — which must never be
* flattened into an empty map, or `set` would cheerfully overwrite it.
*/
private async readMap(): Promise<Record<string, string> | null> {
const raw = await readStoreFile(this.filePath);
if (raw === null) return null;
let parsed: SecretFile;
try {
parsed = JSON.parse(raw) as SecretFile;
} catch (err) {
throw new SecretStoreUnavailableError(
`The secrets file at ${this.filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (parsed.encryption === "none") {
// A plaintext file read by a store that now has a passphrase is fine and
// deliberately not an error: the next write re-encrypts it, so setting
// the env var upgrades an existing file in place.
return { ...(parsed.secrets ?? {}) };
}
if (parsed.encryption !== CIPHER || !parsed.kdf || !parsed.data) {
throw new SecretStoreUnavailableError(
`The secrets file at ${this.filePath} declares an unsupported format (encryption="${String(parsed.encryption)}", version=${String(parsed.version)}). It may have been written by a newer Inspector.`,
);
}
if (!this.passphrase)
throw new SecretFileKeyMismatchError(this.filePath, false);
const parts = decodeParts(parsed.data);
if (!parts) throw new SecretFileKeyMismatchError(this.filePath, true);
try {
const key = await this.deriveKey(parsed.kdf);
const decipher = crypto.createDecipheriv(CIPHER, key, parts.iv);
decipher.setAuthTag(parts.tag);
const plain = Buffer.concat([
decipher.update(parts.body),
decipher.final(),
]).toString("utf-8");
return JSON.parse(plain) as Record<string, string>;
} catch {
// GCM authentication failure is what a wrong passphrase looks like, and
// it is indistinguishable from a tampered/corrupt file — both mean "this
// key does not open this file", which is what the message says.
throw new SecretFileKeyMismatchError(this.filePath, true);
}
}
private async writeMap(map: Record<string, string>): Promise<void> {
let file: SecretFile;
if (this.passphrase) {
const kdf: KdfParams = {
algorithm: "scrypt",
// A fresh salt per write, not per file: rewriting with the same salt
// and a fresh IV would still be safe for GCM, but re-salting costs one
// scrypt derivation we were doing anyway and removes the question.
salt: crypto.randomBytes(16).toString("base64"),
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
};
const key = await this.deriveKey(kdf);
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(CIPHER, key, iv);
const body = Buffer.concat([
cipher.update(JSON.stringify(map), "utf-8"),
cipher.final(),
]);
file = {
version: FORMAT_VERSION,
encryption: CIPHER,
kdf,
data: encodeParts(iv, cipher.getAuthTag(), body),
};
} else {
file = { version: FORMAT_VERSION, encryption: "none", secrets: map };
}
// `writeStoreFile` creates the parent dir, writes atomically, and sets
// 0600 — the same helper the OAuth token store uses, so the two files on
// disk have identical permissions by construction rather than by
// convention.
await writeStoreFile(this.filePath, `${JSON.stringify(file, null, 2)}\n`);
}
/** Run `fn` with the file to itself, so concurrent callers can't lose writes. */
private serialize<T>(fn: () => Promise<T>): Promise<T> {
const run = this.queue.then(fn);
// Swallow on the chain only — the returned promise still rejects, so a
// failed `set` surfaces to its caller while a later `set` is not poisoned
// by it. This assignment is also why `this.queue` needs no `.catch()` of
// its own above: it is only ever `Promise.resolve()` or an
// already-swallowed promise, so it cannot reject.
this.queue = run.then(
() => {},
() => {},
);
return run;
}
async get(serverId: string, field: string): Promise<string | null> {
try {
const map = await this.serialize(() => this.readMap());
return map?.[buildAccount(serverId, field)] ?? null;
} catch {
// Read tolerance, matching `KeyringSecretStore.get`: an unreadable store
// and an absent entry are the same answer to the caller, and hard-failing
// here would break every `GET /api/servers` on a box whose passphrase
// changed.
return null;
}
}
async set(serverId: string, field: string, value: string): Promise<void> {
await this.serialize(async () => {
let map: Record<string, string>;
try {
map = (await this.readMap()) ?? {};
} catch (err) {
// Already a typed refusal (unreadable file / key mismatch) — rethrow
// rather than re-wrapping, so the specific advice survives.
if (err instanceof SecretStoreUnavailableError) throw err;
throw new SecretStoreUnavailableError(
`Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
map[buildAccount(serverId, field)] = value;
try {
await this.writeMap(map);
} catch (err) {
throw new SecretStoreUnavailableError(
`Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
});
}
async delete(serverId: string, field: string): Promise<void> {
await this.deleteWhere((key) => key === buildAccount(serverId, field));
}
async deleteAllForServer(serverId: string): Promise<void> {
const prefix = `${serverId}:`;
await this.deleteWhere((key) => key.startsWith(prefix));
}
/**
* Shared delete body. Silent on every failure, matching the keyring store:
* every reason a delete can fail collapses to "the entry isn't there
* anymore", and `set` is the operation that reports a broken store.
*/
private async deleteWhere(match: (key: string) => boolean): Promise<void> {
try {
await this.serialize(async () => {
const map = await this.readMap();
if (!map) return;
const keys = Object.keys(map).filter(match);
if (keys.length === 0) return;
for (const key of keys) delete map[key];
// An emptied map keeps the file rather than unlinking it: its presence
// is what records which encryption mode this install settled on, so a
// later write can't silently switch modes on the user.
await this.writeMap(map);
});
} catch {
// Intentionally silent — see the doc comment above.
}
}
}
/**
* Best-effort permission repair for a pre-existing secrets file.
*
* `writeStoreFile` passes `mode: 0o600`, but a mode argument only applies
* when the file is *created* — an atomic rename over a file someone made
* `0644` by hand (or restored from a backup, or copied out of an image)
* keeps the loose mode forever. Called once at store selection so the
* guarantee the docs make is the guarantee on disk.
*
* Failures are swallowed: a file we cannot chmod is one we probably cannot
* write either, and `set` is where that gets reported with context.
*/
export async function tightenSecretFilePermissions(
filePath: string,
): Promise<void> {
try {
await fs.chmod(filePath, 0o600);
} catch {
// Non-existent (the common case — nothing written yet), read-only mount,
// or a platform without POSIX modes. Nothing actionable here.
}
}

View file

@ -0,0 +1,323 @@
/**
* Which {@link SecretStore} this process uses, and how the user finds out
* (#1950).
*
* ## The policy
*
* `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright. With it
* unset:
*
* 1. Probe the OS keychain. Reachable → use it. This is the overwhelming
* majority of runs and nothing changes for them.
* 2. Unreachable → fall back, **loudly**. Which way we fall back depends
* on whether the file we would write is going to survive:
* - in a container whose secrets directory is *not* on a mount →
* `memory`;
* - otherwise → `file`.
*
* ## Why automatic
*
* Automatic fallback silently changes where a secret lives, which is a
* real objection to make of a security-adjacent tool. But the objection
* is to *silently*, not to *automatic*: requiring an env var before any
* persistence works at all means the container user's first encounter is
* a 503 with no hint that a working configuration exists — and the store
* they would then be told to set is the one this code would have picked
* anyway. So we take the friendly default and pay for it in noise. The
* downgrade prints a banner, rides `GET /api/config`, and is named in the
* footer of both settings modals — permanently, where the secret is
* typed, rather than once at startup where the person typing it 20
* minutes later will never see it.
*
* ## Why memory can be the better answer
*
* A file in a container's writable layer looks like persistence and isn't:
* `docker run --rm` discards it, and so does any image update. An
* in-memory store makes no such promise, and says so. Mounting a volume —
* which the README already recommends for the catalog — flips the same
* run to `file` with no configuration, because the mount is the user
* stating that this directory is meant to survive.
*/
import * as fsSync from "node:fs";
import * as path from "node:path";
import {
type SecretStorageInfo,
type SecretStoreKind,
secretStorageCaveat,
secretStorageSummary,
} from "../secret-storage-info.js";
import {
FileSecretStore,
tightenSecretFilePermissions,
} from "./file-secret-store.js";
import {
InMemorySecretStore,
KeyringSecretStore,
probeKeyringAvailable,
type SecretStore,
} from "./secret-store.js";
/** Env var naming an explicit store, bypassing the probe entirely. */
export const SECRET_STORE_ENV = "MCP_INSPECTOR_SECRET_STORE";
/** Env var overriding the secrets file location. */
export const SECRET_FILE_ENV = "MCP_INSPECTOR_SECRET_FILE";
const KINDS: SecretStoreKind[] = ["keyring", "file", "memory"];
export interface ResolvedSecretStore {
store: SecretStore;
info: SecretStorageInfo;
}
/**
* Parse {@link SECRET_STORE_ENV}. An unrecognized value returns undefined
* (falling through to the automatic policy) after a warning rather than
* throwing: a typo in a compose file should not stop the Inspector from
* starting, and the user needs the correct spellings more than a stack.
*/
export function parseSecretStoreEnv(
raw: string | undefined,
): SecretStoreKind | undefined {
const value = raw?.trim().toLowerCase();
if (!value) return undefined;
if ((KINDS as string[]).includes(value)) return value as SecretStoreKind;
console.warn(
`[mcp-inspector] Ignoring ${SECRET_STORE_ENV}="${raw}": expected one of ${KINDS.join(", ")}.`,
);
return undefined;
}
/** Default secrets file: beside the catalog, in `~/.mcp-inspector`. */
export function defaultSecretFilePath(): string {
const override = process.env[SECRET_FILE_ENV]?.trim();
if (override) return path.resolve(override);
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
return path.join(homeDir, ".mcp-inspector", "secrets.json");
}
/**
* Are we in a container?
*
* Three signals, any of which is sufficient, because no single one covers
* the runtimes people actually use: `/.dockerenv` is Docker-specific and
* absent under Podman and containerd; the `/proc/1/cgroup` scan catches
* those but reads like the host's under cgroup v2 on some setups; and
* `KUBERNETES_SERVICE_HOST` catches a pod regardless of runtime.
*
* A false positive here is cheap and a false negative is cheaper still —
* this only *biases the fallback* between two working stores, and the
* mount check below is what actually decides. Nothing about correctness
* rests on getting this exactly right, which is why it is allowed to be a
* heuristic at all.
*/
export function isContainer(): boolean {
if (process.env.KUBERNETES_SERVICE_HOST) return true;
try {
if (fsSync.existsSync("/.dockerenv")) return true;
} catch {
// A sandboxed / restricted filesystem. Fall through to the cgroup read.
}
try {
const cgroup = fsSync.readFileSync("/proc/1/cgroup", "utf-8");
return /\b(docker|containerd|kubepods|podman|lxc)\b/.test(cgroup);
} catch {
// Not Linux, or /proc is not mounted. Not a container as far as we know.
return false;
}
}
/**
* Is `dir` — or the nearest existing ancestor of it — a mount point?
*
* The test is `st_dev`: a mount is exactly the case where a directory sits
* on a different device from its parent. That is what makes this work
* regardless of *what* was mounted (named volume, bind mount, tmpfs) and
* without parsing `/proc/mounts`, whose format and container-namespace
* visibility vary.
*
* It walks to the nearest existing ancestor because the directory usually
* does not exist yet on a first run — `~/.mcp-inspector` is created on
* demand. A volume mounted *at* that path exists by definition, so the
* common positive case is found immediately; the walk is what keeps the
* common negative case from being answered by a missing-file error.
*
* `/` is its own parent and is trivially a "mount", so the loop stops
* there and reports false: the container's root filesystem is the writable
* layer this whole check exists to distinguish *against*.
*/
export function isOnMountPoint(dir: string): boolean {
try {
let current = path.resolve(dir);
// Walk up to the first path that exists.
while (!fsSync.existsSync(current)) {
const parent = path.dirname(current);
/* v8 ignore next -- @preserve: the walk can only run out of ancestors
if the filesystem root itself does not exist, which would mean the
process has no cwd to have resolved against. Kept as the loop's
termination guard rather than trusting that invariant. */
if (parent === current) return false;
current = parent;
}
const parent = path.dirname(current);
if (parent === current) return false; // reached `/`
return fsSync.statSync(current).dev !== fsSync.statSync(parent).dev;
} catch {
// Permission denied, a racing unmount, or a platform where `dev` is not
// meaningful. "Not a mount" keeps the fallback on the conservative side
// (memory in a container), which never loses a secret it promised to keep.
return false;
}
}
/** Build the store named by `kind`, plus the descriptor that explains it. */
function buildStore(
kind: SecretStoreKind,
reason: SecretStorageInfo["reason"],
detail?: string,
): ResolvedSecretStore {
if (kind === "keyring") {
return {
store: new KeyringSecretStore(),
info: { kind, reason, durable: true },
};
}
if (kind === "memory") {
return {
store: new InMemorySecretStore(),
info: { kind, reason, durable: false, detail },
};
}
const filePath = defaultSecretFilePath();
const store = new FileSecretStore({ filePath });
// Repair a loosened mode on a pre-existing file before anything reads or
// writes it. Fire-and-forget: it is a best-effort tightening (it swallows
// every failure), and blocking selection on a chmod would make an
// unwritable file a startup problem rather than a `set`-time one.
void tightenSecretFilePermissions(filePath);
return {
store,
info: {
kind,
reason,
path: filePath,
plaintext: !store.encrypted,
durable: true,
detail,
},
};
}
/**
* Pick the fallback store for a host with no keychain. Exported for the
* tests, which drive the container/mount predicates directly rather than
* trying to make a real container appear.
*/
export function chooseFallbackKind(opts: {
container: boolean;
mounted: boolean;
}): SecretStoreKind {
return opts.container && !opts.mounted ? "memory" : "file";
}
/**
* Print the fallback / plaintext warnings.
*
* `console.warn` rather than the pino file logger deliberately: the person
* who needs this is watching a terminal or `docker logs`, and store
* selection happens before (and independently of) any logger being
* configured.
*/
export function warnAboutSecretStorage(info: SecretStorageInfo): void {
if (info.reason === "fallback") {
console.warn(
`\n[mcp-inspector] The OS keychain is not available, so secrets will be kept in: ${secretStorageSummary(info)}.` +
(info.detail
? `\n[mcp-inspector] Keychain error: ${info.detail}`
: "") +
`\n[mcp-inspector] Set ${SECRET_STORE_ENV}=keyring|file|memory to choose explicitly.`,
);
}
const caveat = secretStorageCaveat(info);
if (caveat) console.warn(`[mcp-inspector] ${caveat}`);
}
let resolved: Promise<ResolvedSecretStore> | undefined;
/**
* Resolve the active store, once per process.
*
* Cached because the probe is a native round-trip and, more importantly,
* because the answer must not differ between two callers: the banner, the
* `/api/config` payload, and the store actually doing the writing are
* three consumers of one decision, and a re-probe that flipped would have
* the UI describing a store nobody is using. There is no reset seam for
* the same reason a platform does not grow a keychain mid-run; tests get a
* fresh module (and so a fresh cache) via `vi.resetModules()`.
*/
export function resolveSecretStore(): Promise<ResolvedSecretStore> {
resolved ??= (async (): Promise<ResolvedSecretStore> => {
const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]);
if (configured) {
const result = buildStore(configured, "configured");
warnAboutSecretStorage(result.info);
return result;
}
const probe = await probeKeyringAvailable();
if (probe.available) return buildStore("keyring", "default");
const kind = chooseFallbackKind({
container: isContainer(),
mounted: isOnMountPoint(path.dirname(defaultSecretFilePath())),
});
const result = buildStore(kind, "fallback", probe.detail);
warnAboutSecretStorage(result.info);
return result;
})();
return resolved;
}
/** The active store's descriptor — for the banner and `GET /api/config`. */
export async function getSecretStorageInfo(): Promise<SecretStorageInfo> {
return (await resolveSecretStore()).info;
}
/**
* A `SecretStore` that resolves the real one on first use.
*
* Selection is async (it probes the keychain) but every existing default
* is a synchronous `new KeyringSecretStore()` in the middle of an options
* object — `core/mcp/node/servers.ts`, `core/client/runner.ts`,
* `core/mcp/remote/node/server.ts`. Making those async would ripple
* through their callers to no benefit, since each method is already async
* and can simply await the selection it needs. So the indirection lives
* here, in one place, instead of in three signatures.
*/
class DeferredSecretStore implements SecretStore {
private async target(): Promise<SecretStore> {
return (await resolveSecretStore()).store;
}
async get(serverId: string, field: string): Promise<string | null> {
return (await this.target()).get(serverId, field);
}
async set(serverId: string, field: string, value: string): Promise<void> {
await (await this.target()).set(serverId, field, value);
}
async delete(serverId: string, field: string): Promise<void> {
await (await this.target()).delete(serverId, field);
}
async deleteAllForServer(serverId: string): Promise<void> {
await (await this.target()).deleteAllForServer(serverId);
}
}
/**
* The default store for production call sites — what `new
* KeyringSecretStore()` used to be. Every one of them now routes through
* the same selection, so the CLI, the TUI, and the web backend cannot end
* up writing to different places on the same box.
*/
export function defaultSecretStore(): SecretStore {
return new DeferredSecretStore();
}

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>;