inspector/core/react/useInitialConfig.ts
cliffhall 3452a63101 fix(auth): address Copilot review on the secret-store PR
Six findings, all taken.

- The descriptor reported the *write policy*, not the file. Adding
  `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext
  file flipped `store.encrypted` immediately, so the banner and footer
  claimed "File (encrypted)" while the existing secrets sat readable
  until the next `set`. `FileSecretStore.readOnDiskEncryption()` now
  reads the envelope (never the payload, so it needs no passphrase) and
  the descriptor is built from that. The transitional state gets its own
  `pendingEncryption` flag, which changes the advice without softening
  the verdict — "set a passphrase" is the wrong thing to tell someone who
  already has, so the caveat names the pending write instead.

- The secrets file ignored the configured storage directory. A container
  mounting only that directory was judged unmounted, fell back to
  `memory`, and lost secrets the user had arranged to keep.
  `defaultSecretFilePath()` now follows `MCP_STORAGE_DIR` — the variable
  that already relocates OAuth tokens and `client.json` — between the
  explicit `MCP_INSPECTOR_SECRET_FILE` and the `~/.mcp-inspector`
  default. (The issue comment called it `MCP_INSPECTOR_STORAGE_DIR`; no
  such variable exists.)

- `version` was never checked before the plaintext branch, so a
  `{ version: 2, encryption: "none" }` file was read and silently
  rewritten as version 1, discarding whatever the newer format added —
  the same destroy-to-satisfy-an-additive-request case the encrypted
  branch already refuses. Checked first now, so the answer no longer
  depends on which encryption mode the newer writer chose.

- The doc comment claimed a per-file salt made "a short, memorable value
  safe to use". It does not: the salt defeats precomputation, not
  guessing, and the scrypt cost is deliberately a few milliseconds
  because it runs on every read and write. Both the comment and the
  README now say to use a high-entropy value, generated rather than
  chosen.

- `usableSecretStorage` validated only `kind`, so a partial
  `{ kind: "file" }` was cast through and — because a missing `plaintext`
  is falsy — rendered as the quiet, neutral *encrypted* file. That is the
  confident half-answer the guard exists to reject, produced by the
  absence of information rather than by any claim the backend made.
  `reason`, `durable`, and each kind's own required fields are checked.

- The footer's inline `Tooltip` carried four static props; extracted to a
  `CaveatTooltip` constant per the `.withProps()` rule.

Tests cover each: the policy/file disagreement in both directions and the
no-file-yet fallback, `MCP_STORAGE_DIR` and its precedence against
`MCP_INSPECTOR_SECRET_FILE`, the version refusal (asserting the bytes
survive it) split from the unrecognized-cipher refusal it used to be
conflated with, `readOnDiskEncryption` including the keyless and
unparseable cases, five new rejected descriptor shapes, and the changed
caveat. Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 12:26:33 -04:00

171 lines
7.3 KiB
TypeScript

/**
* React hook over the `GET /api/config` endpoint — the single fetch of the
* static `InitialConfigPayload` the dev/prod web backends serve alongside the
* SPA (see `core/mcp/remote/node/server.ts`). It exposes the three fields the
* web app reads off that payload:
*
* - `sandboxUrl` — the MCP Apps sandbox proxy URL. The backend mounts
* `sandbox_proxy.html` on a separate controller port and advertises the URL
* here; the Apps screen embeds it as the trusted outer iframe. `undefined`
* until the fetch resolves, and whenever the backend omits it (legacy backend,
* or a build without the sandbox controller) — callers treat that as "Apps
* unavailable" rather than a blank iframe.
* - `writable` — whether the session's server list is a writable catalog or a
* read-only session (`--config` / ad-hoc `--server-url`). Defaults to `true`
* until the fetch resolves and whenever the field is absent (a legacy backend
* predating the flag), so the default catalog keeps full CRUD.
* - `version` — the Inspector version the backend reads from the root
* `package.json` (the browser can't read it off disk). `undefined` until the
* fetch resolves, and whenever the backend omits it (legacy backend) — the UI
* renders nothing then.
* - `secretStorage` — which store this session's secrets go to (#1950),
* resolved on the Node side where the keychain probe happens. `undefined`
* until the fetch resolves and on a backend that omits it; the settings-modal
* footers render nothing then, because a guessed answer under a secret field
* is worse than no answer.
*
* This consolidates the three former single-field hooks (`useSandboxUrl`,
* `useServerListWritable`, `useInspectorVersion`), each of which fetched the
* same static payload separately, into one request (#1643).
*
* Fetches on mount, and re-fetches if `baseUrl` or `authToken` changes (rare —
* effectively a full reload; the GET is idempotent). A response that resolves
* after unmount or a re-fetch is dropped rather than overwriting current state.
*/
import { useCallback, useEffect, useState } from "react";
import type { SecretStorageInfo } from "../auth/secret-storage-info.js";
export interface UseInitialConfigOptions {
/** Base URL of the remote server (typically `window.location.origin`). */
baseUrl: string;
/** Optional auth token for the `x-mcp-remote-auth` header. */
authToken?: string;
/** Fetch function to use (default: globalThis.fetch). Useful in tests. */
fetchFn?: typeof fetch;
}
export interface UseInitialConfigResult {
/** The Inspector version, or undefined when unavailable / not yet loaded. */
version: string | undefined;
/** The sandbox proxy URL, or undefined when unavailable / not yet loaded. */
sandboxUrl: string | undefined;
/** Whether the server list is writable (catalog) or read-only (session). */
writable: boolean;
/** Where this session's secrets are stored, or undefined when unknown. */
secretStorage: SecretStorageInfo | undefined;
/** True while the initial fetch is in flight. */
loading: boolean;
}
/** Minimal shape we read from the `/api/config` payload. */
interface ConfigPayload {
version?: unknown;
sandboxUrl?: unknown;
writable?: unknown;
secretStorage?: unknown;
}
/**
* Narrow the payload's `secretStorage` before trusting it.
*
* The footer states, in the UI, where a user's secret is about to go, so a
* malformed or partial descriptor must render as "unknown" rather than as a
* confident half-answer.
*
* Every field the footer reads is checked, not just `kind`. A partial
* `{ kind: "file" }` would otherwise pass, and because a missing `plaintext`
* is falsy it would render as the quiet, neutral *encrypted* file — the
* single most misleading thing this component can say, produced by the
* absence of information rather than by any claim the backend made. Each
* kind's own required fields are checked for the same reason: a file store
* with no `path` cannot answer the question a file store exists to answer.
*/
function usableSecretStorage(value: unknown): SecretStorageInfo | undefined {
if (typeof value !== "object" || value === null) return undefined;
const v = value as Record<string, unknown>;
if (v.kind !== "keyring" && v.kind !== "file" && v.kind !== "memory") {
return undefined;
}
if (
v.reason !== "configured" &&
v.reason !== "default" &&
v.reason !== "fallback"
) {
return undefined;
}
if (typeof v.durable !== "boolean") return undefined;
if (v.kind === "file") {
if (typeof v.path !== "string" || v.path === "") return undefined;
if (typeof v.plaintext !== "boolean") return undefined;
}
return value as SecretStorageInfo;
}
/** Coerce a payload field to a usable non-empty string, else undefined. */
function usableString(value: unknown): string | undefined {
return typeof value === "string" && value ? value : undefined;
}
export function useInitialConfig(
opts: UseInitialConfigOptions,
): UseInitialConfigResult {
const { baseUrl, authToken, fetchFn } = opts;
const doFetch = fetchFn ?? globalThis.fetch;
const base = baseUrl.replace(/\/$/, "");
const [version, setVersion] = useState<string | undefined>(undefined);
const [sandboxUrl, setSandboxUrl] = useState<string | undefined>(undefined);
// Default writable so the common (catalog) case shows CRUD immediately and a
// legacy backend that omits the field keeps working.
const [writable, setWritable] = useState<boolean>(true);
const [secretStorage, setSecretStorage] = useState<
SecretStorageInfo | undefined
>(undefined);
const [loading, setLoading] = useState<boolean>(true);
// `isCancelled` lets the effect drop a response that resolves after unmount or
// after a re-run (baseUrl/authToken change), so a stale payload can't overwrite
// current state. React 18 no longer warns on setState-after-unmount — it's a
// silent no-op — so the guard is about correctness, not the warning.
const load = useCallback(
async (isCancelled: () => boolean): Promise<void> => {
const headers: Record<string, string> = {};
if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`;
try {
const res = await doFetch(`${base}/api/config`, {
method: "GET",
headers,
});
if (isCancelled() || !res.ok) return;
const body = (await res.json()) as ConfigPayload;
if (isCancelled()) return;
// Tolerate missing/non-usable fields — a legacy backend that omits any
// of them leaves that value at its "unavailable" default rather than
// showing a bogus value.
setVersion(usableString(body.version));
setSandboxUrl(usableString(body.sandboxUrl));
// Only an explicit `false` makes the list read-only; a missing field
// (legacy backend) stays writable.
setWritable(body.writable !== false);
setSecretStorage(usableSecretStorage(body.secretStorage));
} catch {
// Network error / aborted fetch: leave every field at its default
// (version/sandboxUrl undefined, writable true).
} finally {
if (!isCancelled()) setLoading(false);
}
},
[base, authToken, doFetch],
);
useEffect(() => {
let cancelled = false;
void load(() => cancelled);
return () => {
cancelled = true;
};
}, [load]);
return { version, sandboxUrl, writable, secretStorage, loading };
}