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>
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
import type { SecretStore } from "../../auth/node/secret-store.js";
|
|
import { defaultSecretStore } from "../../auth/node/secret-store-selection.js";
|
|
import type { InspectorServerSettings, MCPServerConfig } from "../types.js";
|
|
import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js";
|
|
import { mcpConfigToServerEntries } from "../serverList.js";
|
|
import {
|
|
applyOverrides,
|
|
resolveServerConfigs,
|
|
resolveServerSource,
|
|
serverSourceConflict,
|
|
readServerListFile,
|
|
hasAdHocServerOptions,
|
|
withDefaultCatalogPath,
|
|
type ServerConfigOptions,
|
|
} from "./config.js";
|
|
import { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
|
|
|
|
/**
|
|
* A server resolved from a catalog/config file or an ad-hoc target, paired with
|
|
* the per-server settings (headers, timeouts, OAuth, etc.) lifted from the file.
|
|
* `config` is the SDK-facing transport config; `settings` is the v2
|
|
* `InspectorServerSettings` model (post-#1358 top-level shape).
|
|
*/
|
|
export type ResolvedServer = {
|
|
config: MCPServerConfig;
|
|
settings?: InspectorServerSettings;
|
|
};
|
|
|
|
/** Loader options: the shared source flags plus a single `--header` set that is
|
|
* broadcast into every resolved server's settings. */
|
|
export type ServerLoadOptions = ServerConfigOptions & {
|
|
headers?: Record<string, string>;
|
|
/** Test injection; defaults to the selected store (see
|
|
* `secret-store-selection.ts`) for catalog/config loads. */
|
|
secretStore?: SecretStore;
|
|
};
|
|
|
|
/**
|
|
* Build an `InspectorServerSettings` carrying only `--header` overrides (all
|
|
* other fields defaulted). Returns undefined when no headers are given so it can
|
|
* be used as a no-op base in `mergeSettings`.
|
|
*/
|
|
export function headersToServerSettings(
|
|
headers?: Record<string, string>,
|
|
): InspectorServerSettings | undefined {
|
|
if (!headers || Object.keys(headers).length === 0) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
|
|
env: [],
|
|
metadata: [],
|
|
connectionTimeout: 0,
|
|
requestTimeout: 0,
|
|
taskTtl: DEFAULT_TASK_TTL_MS,
|
|
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
|
|
autoRefreshOnListChanged: false,
|
|
paginatedLists: false,
|
|
roots: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Overlay CLI `--header` values onto the settings lifted from the file. Only the
|
|
* `headers` field is overridden — timeouts, OAuth, and the rest of the file's
|
|
* settings are preserved.
|
|
*/
|
|
function mergeSettings(
|
|
base: InspectorServerSettings | undefined,
|
|
headers?: Record<string, string>,
|
|
): InspectorServerSettings | undefined {
|
|
const fromHeaders = headersToServerSettings(headers);
|
|
if (!fromHeaders) return base;
|
|
if (!base) return fromHeaders;
|
|
return { ...base, headers: fromHeaders.headers };
|
|
}
|
|
|
|
/**
|
|
* Resolve every server from a catalog/config file (or a single ad-hoc target)
|
|
* into `{ config, settings }`, lifting disk-level headers/timeouts/OAuth into
|
|
* `InspectorServerSettings` via `mcpConfigToServerEntries`. Shared by the CLI
|
|
* and TUI so both apply the same file→settings resolution (issue #1482).
|
|
*
|
|
* Applies the default writable catalog when no source/ad-hoc target is given,
|
|
* enforces the `--catalog`/`--config`/ad-hoc conflict matrix, and seeds an empty
|
|
* writable catalog on first run (a missing read-only `--config` still errors).
|
|
*/
|
|
export async function loadServerEntries(
|
|
serverOptions: ServerLoadOptions,
|
|
): Promise<Record<string, ResolvedServer>> {
|
|
serverOptions = withDefaultCatalogPath(serverOptions);
|
|
|
|
const conflict = serverSourceConflict({
|
|
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
|
|
hasConfig: Boolean(serverOptions.configPath?.trim()),
|
|
hasAdHoc: hasAdHocServerOptions(serverOptions),
|
|
});
|
|
if (conflict) {
|
|
throw new Error(conflict);
|
|
}
|
|
|
|
const source = resolveServerSource(serverOptions);
|
|
|
|
if (source) {
|
|
let config = readServerListFile(source.path, source.writable);
|
|
const secretStore = serverOptions.secretStore ?? defaultSecretStore();
|
|
config = await rehydrateMcpConfigFromKeychain(config, secretStore);
|
|
const entries = mcpConfigToServerEntries(config);
|
|
const result: Record<string, ResolvedServer> = {};
|
|
for (const entry of entries) {
|
|
result[entry.name] = {
|
|
config: applyOverrides(entry.config, {
|
|
env: serverOptions.env,
|
|
cwd: serverOptions.cwd,
|
|
}),
|
|
// Deliberate broadcast: a single `--header` set is merged into EVERY
|
|
// server in the catalog/config (fine for the common single-server case;
|
|
// for multi-server files, prefer per-server headers in the file itself).
|
|
settings: mergeSettings(entry.settings, serverOptions.headers),
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const configs = resolveServerConfigs(serverOptions, "multi");
|
|
if (configs.length === 0) {
|
|
throw new Error(
|
|
"At least one server is required. Use --catalog/--config <path> or an ad-hoc target (command/URL).",
|
|
);
|
|
}
|
|
return {
|
|
default: {
|
|
config: configs[0]!,
|
|
settings: headersToServerSettings(serverOptions.headers),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pick a single resolved server for runners that connect to exactly one (the
|
|
* CLI). With `serverName`, returns that entry or errors listing the available
|
|
* names; otherwise requires exactly one server in the source.
|
|
*
|
|
* The unknown-name error is source-agnostic ("not found" rather than "not found
|
|
* in config file") because `entries` may come from a file *or* a single ad-hoc
|
|
* target — e.g. `--server foo` alongside a positional command resolves to just
|
|
* `{ default }`, where "in config file" would be misleading.
|
|
*/
|
|
export function selectServerEntry(
|
|
entries: Record<string, ResolvedServer>,
|
|
serverName?: string,
|
|
): ResolvedServer {
|
|
const names = Object.keys(entries);
|
|
if (serverName) {
|
|
const entry = entries[serverName];
|
|
if (!entry) {
|
|
throw new Error(
|
|
`Server '${serverName}' not found. Available servers: ${names.join(", ")}`,
|
|
);
|
|
}
|
|
return entry;
|
|
}
|
|
if (names.length === 0) {
|
|
throw new Error("No servers found in config file");
|
|
}
|
|
if (names.length > 1) {
|
|
throw new Error(
|
|
`Multiple servers found in config file. Please specify one with --server. Available servers: ${names.join(", ")}`,
|
|
);
|
|
}
|
|
return entries[names[0]!]!;
|
|
}
|