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,105 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
import { SecretStorageFooter } from "./SecretStorageFooter";
const keyring: SecretStorageInfo = {
kind: "keyring",
reason: "default",
durable: true,
};
const meta: Meta<typeof SecretStorageFooter> = {
title: "Elements/SecretStorageFooter",
component: SecretStorageFooter,
args: { info: keyring },
};
export default meta;
type Story = StoryObj<typeof SecretStorageFooter>;
// The ordinary case on a desktop install: the OS keychain, stated quietly.
export const Keychain: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByText("Secrets: OS keychain"),
).toBeInTheDocument();
await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute(
"data-tone",
"neutral",
);
},
};
// A container with a mounted volume and `MCP_INSPECTOR_SECRET_KEY` set: durable,
// encrypted, and therefore still quiet — the path is the useful part.
export const EncryptedFile: Story = {
args: {
info: {
kind: "file",
reason: "fallback",
durable: true,
plaintext: false,
path: "/home/node/.mcp-inspector/secrets.json",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByText("Secrets: File (encrypted)"),
).toBeInTheDocument();
await expect(
canvas.getByText("/home/node/.mcp-inspector/secrets.json"),
).toBeInTheDocument();
},
};
// The same container without a passphrase. The caveat is rendered inline and in
// the warning tone — this is what "plaintext gets a loud banner" looks like at
// the point where the secret is actually typed.
export const UnencryptedFile: Story = {
args: {
info: {
kind: "file",
reason: "fallback",
durable: true,
plaintext: true,
path: "/home/node/.mcp-inspector/secrets.json",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByText("Secrets: File (unencrypted)"),
).toBeInTheDocument();
await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute(
"data-tone",
"warn",
);
},
};
// The published container with nothing mounted: session-scoped, and it says so
// rather than promising a durability the writable layer can't keep.
export const InMemory: Story = {
args: { info: { kind: "memory", reason: "fallback", durable: false } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByText("Secrets: Memory (this session only)"),
).toBeInTheDocument();
await expect(
canvas.getByText(/lost when the Inspector exits/),
).toBeInTheDocument();
},
};
// A backend that reports no store (legacy build): the band is absent entirely.
export const Unknown: Story = {
args: { info: undefined },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByTestId("secret-storage-footer")).toBeNull();
},
};

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { SecretStorageFooter } from "./SecretStorageFooter";
const keyring: SecretStorageInfo = {
kind: "keyring",
reason: "default",
durable: true,
};
const encryptedFile: SecretStorageInfo = {
kind: "file",
reason: "fallback",
durable: true,
plaintext: false,
path: "/home/node/.mcp-inspector/secrets.json",
};
const plaintextFile: SecretStorageInfo = { ...encryptedFile, plaintext: true };
const memory: SecretStorageInfo = {
kind: "memory",
reason: "fallback",
durable: false,
};
describe("SecretStorageFooter", () => {
it("names the keychain with no caveat and no warning tone", () => {
renderWithMantine(<SecretStorageFooter info={keyring} />);
expect(screen.getByText("Secrets: OS keychain")).toBeInTheDocument();
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
"data-tone",
"neutral",
);
});
it("shows the path for an encrypted file, quietly", () => {
// Nothing is wrong here, so the band must not shout — the path is the
// useful part and the tone stays neutral.
renderWithMantine(<SecretStorageFooter info={encryptedFile} />);
expect(screen.getByText("Secrets: File (encrypted)")).toBeInTheDocument();
expect(
screen.getByText("/home/node/.mcp-inspector/secrets.json"),
).toBeInTheDocument();
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
"data-tone",
"neutral",
);
});
it("renders the plaintext caveat inline, not only on hover", () => {
// This is the "loud banner" decision made concrete: a warning you have
// to hover to discover is not a warning. The caveat takes the visible
// slot, displacing the path (which stays in the tooltip).
renderWithMantine(<SecretStorageFooter info={plaintextFile} />);
expect(screen.getByText("Secrets: File (unencrypted)")).toBeInTheDocument();
expect(
screen.getByText(/Set MCP_INSPECTOR_SECRET_KEY to encrypt them/),
).toBeInTheDocument();
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
"data-tone",
"warn",
);
});
it("warns that an in-memory store loses secrets on exit", () => {
renderWithMantine(<SecretStorageFooter info={memory} />);
expect(
screen.getByText("Secrets: Memory (this session only)"),
).toBeInTheDocument();
expect(
screen.getByText(/lost when the Inspector exits/),
).toBeInTheDocument();
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
"data-tone",
"warn",
);
});
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.
renderWithMantine(<SecretStorageFooter info={undefined} />);
expect(screen.queryByTestId("secret-storage-footer")).toBeNull();
});
});

View file

@ -0,0 +1,139 @@
import { Group, Text, Tooltip } from "@mantine/core";
import { TbAlertTriangle, TbDeviceDesktop, TbLock } from "react-icons/tb";
import {
secretStorageCaveat,
secretStorageLabel,
secretStorageTone,
type SecretStorageInfo,
} from "@inspector/core/auth/secret-storage-info.js";
/**
* Height of the footer band. Matches `InspectorView`'s `FOOTER_HEIGHT`
* deliberately: these are the app's only permanent status strips, and a
* modal footer standing a few pixels taller than the one behind it would
* read as a different kind of thing.
*/
export const SECRET_STORAGE_FOOTER_HEIGHT = 32;
export interface SecretStorageFooterProps {
/**
* The active store, from `GET /api/config`. Renders nothing when
* undefined — a backend that didn't report one leaves the question
* genuinely open, and an invented answer directly beneath a secret field
* is worse than silence.
*/
info?: SecretStorageInfo;
}
// A thin band, same height as the app footer. Mantine's compound Modal has no
// `Modal.Footer`, so it renders as the last child of `Modal.Content`, after the
// `Modal.Body`, and its theme variant pins it with `position: sticky` — the
// mirror of Mantine's own sticky header, because `Modal.Content` scrolls
// header, body and footer as one. `nowrap` keeps it to one line at any modal
// width; the truncating detail text on the right absorbs a narrow modal.
const FooterBand = Group.withProps({
variant: "secretStorageFooter",
h: SECRET_STORAGE_FOOTER_HEIGHT,
px: "md",
gap: 6,
align: "center",
wrap: "nowrap",
w: "100%",
});
const FooterLabel = Text.withProps({
size: "xs",
fw: 500,
variant: "nowrap",
});
// The detail — a path, or the caveat sentence — is the part allowed to lose
// characters, since the label before it carries the answer.
//
// `lineClamp` rather than `truncate`, and the difference is load-bearing.
// `Modal.Content` sizes its scroll container to its children's **max-content**
// width, and `truncate` sets `white-space: nowrap`, whose max-content is the
// entire sentence — so the band grew the container to 770px inside a 620px
// modal and the *form above it* got clipped at the right edge (measured).
// Clamped text still wraps internally, so its max-content is merely its
// longest word, and the container stays the modal's width; the clamp then
// keeps it to the one line this 32px band has room for. `miw: 0` + `flex: 1`
// let it shrink to the leftover space rather than to its content.
const FooterDetail = Text.withProps({
size: "xs",
lineClamp: 1,
miw: 0,
flex: 1,
});
const ICON_SIZE = 14;
/**
* The permanent "here is where your secrets go" strip at the bottom of the
* Client Settings and Server Settings modals (#1950).
*
* Those two dialogs are the only places the Inspector accepts a secret —
* an OAuth client secret, an enterprise IdP client secret, a stdio `env:`
* value — so they are where the answer belongs. The alternatives were
* considered and rejected: a startup banner is seen once, by whoever
* started the process, possibly hours before anyone types anything; a
* toast is seen once and then gone; a dismissible banner is, by design,
* the thing a user makes disappear before doing the work it describes.
* This is always present, is not dismissible, and has no empty state
* beyond "the backend didn't say".
*
* When the store is lossy or unencrypted the caveat is rendered *inline*
* rather than left to the tooltip. That is the whole point of the
* "plaintext gets a loud warning" decision: a warning you have to hover to
* discover is not one. The tooltip carries the full sentence for the case
* where a narrow modal truncates it, and — for the quiet stores — the file
* path.
*
* Deliberately read-only for now. Choosing the store from here is the
* obvious follow-up, but the gap this closes is that users could not find
* out *at all*, and closing that does not require a control.
*/
export function SecretStorageFooter({ info }: SecretStorageFooterProps) {
if (!info) return null;
const caveat = secretStorageCaveat(info);
const warn = secretStorageTone(info) === "warn";
const color = warn
? "var(--inspector-warning-text)"
: "var(--inspector-text-secondary)";
// The caveat wins the visible slot when there is one; otherwise the path,
// which only the (quiet) encrypted-file case has.
const detail = caveat ?? info.path;
const Icon = warn
? TbAlertTriangle
: info.kind === "keyring"
? TbDeviceDesktop
: TbLock;
const band = (
<FooterBand
data-testid="secret-storage-footer"
data-tone={warn ? "warn" : "neutral"}
>
{/* A react-icons glyph, not a Mantine factory component — outside the
`.withProps()` rule, and it takes `color` as a prop rather than a
style. */}
<Icon size={ICON_SIZE} color={color} aria-hidden />
<FooterLabel c={color}>Secrets: {secretStorageLabel(info)}</FooterLabel>
{detail && <FooterDetail c={color}>{detail}</FooterDetail>}
</FooterBand>
);
// Tooltip content: the caveat plus the path when both exist (the unencrypted
// file case), since the visible slot can only show one of them.
const tip = [caveat, info.path].filter(Boolean).join(" ");
if (!tip) return band;
return (
// `multiline` + a width so the sentence wraps instead of forming a tooltip
// wider than the modal it explains.
<Tooltip label={tip} multiline w={320} position="top" withArrow>
{band}
</Tooltip>
);
}