* 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:
parent
12b8eb11fd
commit
91f02fd6db
1 changed files with 1281 additions and 157 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue