feat(web): catalog vs read-only session launch model + apply --header (#1481, #1483) (#1496)

* feat(web): catalog vs read-only session launch model + apply --header (#1481, #1483)

Per specification/v2_catalog_launch_config.md, `mcp-inspector --web` now selects
the backend's server-list source and a session-wide writability, instead of
seeding an inert `initialMcpConfig` the UI never read:

- `--catalog <path>` (and `MCP_CATALOG_PATH`) → active WRITABLE catalog
  (seed/CRUD/watch), like the default catalog.
- `--config <path>` → READ-ONLY session file: served in the UI but never
  written, seeded, or plaintext-secret-migrated — safe to point at a foreign
  config (e.g. claude_desktop_config.json).
- ad-hoc `--server-url`/command (+ `--header`) → one server held IN MEMORY
  (no file written); `--header` is lifted onto the entry so the seeded
  connection actually uses it (closes the #1483 warn-only no-op).
- nothing → default catalog (writable), unchanged.

Backend (`createRemoteApp`): new `writable` + `initialServers` options. GET
/api/servers branches three ways (in-memory / read-only file with no
seed+no migrate / writable catalog); the four mutating routes return 403 when
read-only; `/api/config` carries `writable`; the file watcher is skipped for
in-memory sessions. `--catalog`/`--config` are mutually exclusive and neither
combines with an ad-hoc target or `--header`; `--header` requires an ad-hoc
HTTP/SSE server (clear errors otherwise — no silent no-op).

UI: `useServerListWritable` reads `writable` from /api/config; the Servers
screen hides add/edit/clone/remove/reorder/settings and shows a read-only
banner when false. All new props default to writable=true (back-compatible).

Docs: spec gaps G2/G3/G4 updated; launcher README documents the flags.

Verified end-to-end against the built launcher: --config (writable:false,
file byte-stable, mutations 403), ad-hoc --header (in-memory, header lifted),
and --catalog (writable:true, POST persists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): address review — Mantine <Code> in read-only banner, clarify watcher comment

- Replace raw <code> with Mantine <Code> in the read-only-session Alert
  (repo convention: prefer Mantine primitives over raw HTML).
- Add a comment near `watchable` clarifying that a read-only `--config` file
  is still watched on purpose (read-only blocks UI writes, not external
  observation; `writable` does not gate watching).

Deferred (per reviewer): consolidating useSandboxUrl + useServerListWritable
into one useConfig hook to avoid the duplicate /api/config fetch — optional
follow-up. The redundant ad-hoc header type-guard is kept as defensive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-16 14:24:58 -04:00 • committed by GitHub
commit 91f02fd6db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -62,6 +62,13 @@ export interface InitialConfigPayload {
defaultServerUrl?: string;
defaultCwd?: string;
defaultEnvironment: Record<string, string>;
/**
* Whether the session's server list is writable (catalog) or read-only
* (a `--config` session file or ad-hoc seeded server). Absent → treated as
* true by clients for backward compatibility. Drives whether the web UI
* shows catalog CRUD affordances.
*/
writable?: boolean;
}
export interface RemoteServerOptions {
@ -87,6 +94,26 @@ export interface RemoteServerOptions {
/** Optional path for the user's server list file (/api/servers). Default: ~/.mcp-inspector/mcp.json */
mcpConfigPath?: string;
/**
* When false, the server list is read-only for this session: all
* `/api/servers` mutations (POST/PUT/DELETE/order) are rejected with 403,
* the file is never seeded on first read, and on-disk plaintext secrets are
* not migrated into the keychain (migration is a write). Used for the web
* launcher's read-only `--config` session file and ad-hoc seeded servers, so
* a foreign config passed at launch is never silently rewritten. Defaults to
* true (the default catalog and `--catalog` are writable).
*/
writable?: boolean;
/**
* In-memory server list served by GET /api/servers instead of reading from
* disk. Set by the web launcher to seed an ad-hoc `--server-url` / command
* target (with `--header`) without writing any file. Implies a read-only
* session — pass `writable: false` alongside it. When set, `mcpConfigPath`
* is ignored for reads and the file watcher is not started.
*/
initialServers?: MCPConfig;
/** Optional sandbox URL for MCP Apps tab. When set, GET /api/config includes sandboxUrl. */
sandboxUrl?: string;
@ -298,6 +325,19 @@ export function createRemoteApp(
const secretStore: SecretStore =
options.secretStore ?? new KeyringSecretStore();
// Read-only session support (#1481/#1483). `writable: false` rejects every
// /api/servers mutation and suppresses the seed/migrate writes on read.
// `initialServers` serves an in-memory list (ad-hoc launch) with no file at
// all. Both default off so the catalog path is unchanged.
const writable = options.writable ?? true;
const inMemoryServers = options.initialServers;
// Watch only a real on-disk catalog/session file — never for an in-memory
// ad-hoc list (there is nothing to watch and the default path is unrelated).
// A read-only `--config` *file* is still watched on purpose: read-only blocks
// UI-driven writes, not external observation, so an external edit to the file
// should still refresh the UI. (`writable` does not gate watching.)
const watchable = !inMemoryServers;
// --- /api/servers/events: file-watch fanout state -----------------------
//
// Subscribers are SSE writers added by GET /api/servers/events and removed
@ -447,9 +487,11 @@ export function createRemoteApp(
}
app.get("/api/config", (c) => {
const payload = options.sandboxUrl
? { ...options.initialConfig, sandboxUrl: options.sandboxUrl }
: options.initialConfig;
const payload = {
...options.initialConfig,
writable,
...(options.sandboxUrl ? { sandboxUrl: options.sandboxUrl } : {}),
};
return c.json(payload);
});
@ -1420,8 +1462,31 @@ export function createRemoteApp(
return undefined;
};
// Reject catalog mutations in a read-only session. Checked first in each
// mutating route — before body parse or existence checks — so a read-only
// backend answers uniformly and never leaks whether an id exists (403, not
// 404). Returns a Response to short-circuit, or undefined to proceed.
const requireWritable = (c: Context): Response | undefined => {
if (writable) return undefined;
return c.json(
{ error: "Server list is read-only for this session." },
403,
);
};
app.get("/api/servers", async (c) => {
try {
// In-memory session (ad-hoc launch): serve the seeded list verbatim.
// No disk read, no seed, no migration — there is no file. Ad-hoc env /
// header values live as plaintext in this list and flow straight to
// POST /api/mcp/connect, which is what makes `--header` take effect.
if (inMemoryServers) {
const normalized: MCPConfig = {
mcpServers: normalizeMcpServers(inMemoryServers.mcpServers),
};
return c.json(await rehydrateConfig(normalized));
}
// Fast path: peek at the file without taking the write lock. Most
// GETs land here — file exists, no plaintext to migrate. The
// unlocked read is safe because we don't write anything in this
@ -1433,9 +1498,19 @@ export function createRemoteApp(
const onDisk: MCPConfig = {
mcpServers: normalizeMcpServers(parsed?.mcpServers),
};
// Read-only session file (`--config`): never migrate (a write) and
// never seed. Return the file as-is so a foreign config is shown but
// its bytes — and any plaintext secrets — are left untouched on disk.
if (!writable) {
return c.json(await rehydrateConfig(onDisk));
}
if (!hasPlaintextSecrets(onDisk)) {
return c.json(await rehydrateConfig(onDisk));
}
} else if (!writable) {
// Read-only and the file is absent: present an empty list rather than
// seeding the default sample servers into a path the user pointed us at.
return c.json({ mcpServers: {} });
}
// Slow path: either the file is missing (first-launch seed) or it
@ -1480,6 +1555,8 @@ export function createRemoteApp(
});
app.post("/api/servers", async (c) => {
const ro = requireWritable(c);
if (ro) return ro;
let body: { id?: unknown; config?: unknown; settings?: unknown };
try {
body = (await c.req.json()) as {
@ -1553,6 +1630,8 @@ export function createRemoteApp(
// values, so we just permute the existing stripped entries and reuse the
// same atomic-write + watcher-notify path as the other mutators.
app.put("/api/servers/order", async (c) => {
const ro = requireWritable(c);
if (ro) return ro;
let body: { order?: unknown };
try {
body = (await c.req.json()) as { order?: unknown };
@ -1605,6 +1684,8 @@ export function createRemoteApp(
});
app.put("/api/servers/:id", async (c) => {
const ro = requireWritable(c);
if (ro) return ro;
const originalId = c.req.param("id");
if (!originalId || !validateStoreId(originalId)) {
return c.json({ error: "Invalid id" }, 400);
@ -1766,6 +1847,8 @@ export function createRemoteApp(
});
app.delete("/api/servers/:id", async (c) => {
const ro = requireWritable(c);
if (ro) return ro;
const id = c.req.param("id");
if (!id || !validateStoreId(id)) {
return c.json({ error: "Invalid id" }, 400);
@ -1804,8 +1887,13 @@ export function createRemoteApp(
const send = (data: string): void => {
void stream.writeSSE({ event: "change", data });
};
serverEventSubscribers.add(send);
ensureWatcher();
// An in-memory ad-hoc list never changes and has no file to watch, so we
// hold the stream open (so the client's fetch resolves cleanly) but
// register no subscriber and start no watcher — there is nothing to emit.
if (watchable) {
serverEventSubscribers.add(send);
ensureWatcher();
}
stream.onAbort(() => {
serverEventSubscribers.delete(send);