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

@ -18,6 +18,7 @@ import { formatHostForUrl } from "../../../core/node/hostUrl.ts";
import { createSandboxController } from "./sandbox-controller.js";
import { injectAuthToken } from "./inject-auth-token.js";
import type { WebServerConfig } from "./web-server-config.js";
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
import {
webServerConfigToInitialPayload,
printServerBanner,
@ -49,6 +50,11 @@ export async function startHonoServer(
(config.dangerouslyOmitAuth ? "" : randomBytes(32).toString("hex"));
const rootPath = config.staticRoot ?? __dirname;
// Resolve the secret store before the API is built so the descriptor the
// browser reads (`/api/config`) and the one the banner prints come from the
// same resolution — the selection is cached, so this is also the only place
// the keychain probe actually runs.
const secretStorage = await getSecretStorageInfo();
const { app: apiApp, close: closeApi } = createRemoteApp({
authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken,
@ -60,7 +66,7 @@ export async function startHonoServer(
allowedOrigins: config.allowedOrigins,
sandboxUrl: sandboxController.getUrl() ?? undefined,
logger: config.logger,
initialConfig: webServerConfigToInitialPayload(config),
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
});
const app = new Hono();
@ -123,6 +129,7 @@ export async function startHonoServer(
info.port,
resolvedAuthToken,
sandboxUrl ?? undefined,
secretStorage,
);
if (config.autoOpen) {
// Never a bare `open(url)`: this callback is synchronous, so a

View file

@ -18,6 +18,7 @@ import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
import { createSandboxController } from "./sandbox-controller.js";
import { injectAuthToken } from "./inject-auth-token.js";
import type { WebServerConfig } from "./web-server-config.js";
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
import {
webServerConfigToInitialPayload,
printServerBanner,
@ -66,6 +67,9 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
allowedOrigins: config.allowedOrigins,
});
await sandboxController.start();
// Resolved before the API is built so `/api/config` and the banner
// report the same store (the selection itself is cached process-wide).
const secretStorage = await getSecretStorageInfo();
const {
app: honoApp,
@ -81,7 +85,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
allowedOrigins: config.allowedOrigins,
sandboxUrl: sandboxController.getUrl() ?? undefined,
logger: config.logger,
initialConfig: webServerConfigToInitialPayload(config),
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
});
// Expose the resolved token to `transformIndexHtml`. Left empty when
@ -111,6 +115,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
actualPort,
resolvedToken,
sandboxUrl ?? undefined,
secretStorage,
);
if (config.autoOpen) {

View file

@ -15,6 +15,8 @@ import {
LEGACY_AUTH_TOKEN_ENV,
} from "../../../core/mcp/remote/constants.ts";
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
import type { SecretStorageInfo } from "../../../core/auth/secret-storage-info.ts";
import { secretStorageSummary } from "../../../core/auth/secret-storage-info.ts";
import { readInspectorVersionSafe } from "../../../core/node/version.ts";
import { resolveSandboxPort } from "./sandbox-controller.js";
import { resolveBindHostname } from "./resolve-bind-host.js";
@ -112,8 +114,17 @@ function defaultEnvironmentFromProcess(
*/
export function webServerConfigToInitialPayload(
config: WebServerConfig,
secretStorage?: SecretStorageInfo,
): InitialConfigPayload {
return { ...transportDefaults(config), version: inspectorVersion };
return {
...transportDefaults(config),
version: inspectorVersion,
// Omitted rather than defaulted when the caller didn't resolve a store:
// the UI treats an absent descriptor as "unknown" and shows nothing,
// which is the only honest rendering. A default of "keychain" would put a
// confident wrong answer under the field where a secret is typed.
...(secretStorage ? { secretStorage } : {}),
};
}
/** The transport-specific defaults half of the `/api/config` payload. */
@ -168,6 +179,7 @@ export function printServerBanner(
actualPort: number,
resolvedToken: string,
sandboxUrl: string | undefined,
secretStorage?: SecretStorageInfo,
): string {
// Advertise `localhost` for a wildcard bind (`http://0.0.0.0:PORT` is an
// awkward URL to click and points the user at a reachable, allow-listed
@ -197,6 +209,14 @@ export function printServerBanner(
console.log(` Auth token: ${resolvedToken}\n`);
}
// Where a secret typed into this session lands (#1950). Printed for every
// store, not only the surprising ones: the fallback already warns
// separately at selection time, and a line that appears only when
// something is wrong can't be used to confirm that nothing is.
if (secretStorage) {
console.log(` Secrets: ${secretStorageSummary(secretStorage)}\n`);
}
if (config.autoOpen) {
console.log("Opening browser...");
}