* 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
4 changed files with 1281 additions and 157 deletions
|
|
@ -7,7 +7,11 @@
|
|||
import { resolve, join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import type { MCPServerConfig } from "../../../core/mcp/types.ts";
|
||||
import type {
|
||||
MCPConfig,
|
||||
MCPServerConfig,
|
||||
StoredMCPServer,
|
||||
} from "../../../core/mcp/types.ts";
|
||||
import {
|
||||
resolveServerConfigs,
|
||||
parseKeyValuePair,
|
||||
|
|
@ -31,6 +35,22 @@ function ensureStdioCwd(config: MCPServerConfig): MCPServerConfig {
|
|||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a readable catalog id for an ad-hoc server seeded from CLI flags:
|
||||
* the URL host for HTTP/SSE, or the command basename for stdio. Falls back to
|
||||
* "server" so the id is always a non-empty `mcpServers` map key.
|
||||
*/
|
||||
function deriveSeedServerId(config: MCPServerConfig): string {
|
||||
if (config.type === "sse" || config.type === "streamable-http") {
|
||||
try {
|
||||
return new URL(config.url).host || "server";
|
||||
} catch {
|
||||
return "server";
|
||||
}
|
||||
}
|
||||
return config.command?.split(/[\\/]/).pop() || "server";
|
||||
}
|
||||
|
||||
export async function runWeb(argv: string[]): Promise<number> {
|
||||
const program = new Command();
|
||||
|
||||
|
|
@ -53,7 +73,8 @@ export async function runWeb(argv: string[]): Promise<number> {
|
|||
parseKeyValuePair,
|
||||
{},
|
||||
)
|
||||
.option("--config <path>", "config file path")
|
||||
.option("--catalog <path>", "writable catalog file path")
|
||||
.option("--config <path>", "read-only session config file path")
|
||||
.option("--server <name>", "server name from config file")
|
||||
.option("--transport <type>", "transport type (stdio, sse, http)")
|
||||
.option("--server-url <url>", "server URL for SSE/HTTP transport")
|
||||
|
|
@ -68,6 +89,7 @@ export async function runWeb(argv: string[]): Promise<number> {
|
|||
.parse(preArgs);
|
||||
|
||||
const opts = program.opts() as {
|
||||
catalog?: string;
|
||||
config?: string;
|
||||
server?: string;
|
||||
e?: Record<string, string>;
|
||||
|
|
@ -82,50 +104,139 @@ export async function runWeb(argv: string[]): Promise<number> {
|
|||
const target = [...args, ...postArgs];
|
||||
const isDev = !!opts.dev;
|
||||
|
||||
const hasServerInput =
|
||||
opts.config ||
|
||||
// Catalog source for the web backend (spec: v2_catalog_launch_config.md):
|
||||
// - `--catalog <path>` / MCP_CATALOG_PATH → that file is the active WRITABLE
|
||||
// catalog (CRUD, seed-if-missing, file-watch) — like the default catalog.
|
||||
// - `--config <path>` → a READ-ONLY session file: shown in the UI but never
|
||||
// written, seeded, or migrated (so a foreign config is safe to point at).
|
||||
// - ad-hoc `--server-url` / command target → a one-server READ-ONLY session
|
||||
// held in memory (no file written), with `--header` lifted onto the entry
|
||||
// so the seeded connection actually uses those headers (#1483).
|
||||
// - nothing → default catalog (writable), backend uses its default path.
|
||||
// `initialMcpConfig` stays populated for the ad-hoc case only so the legacy
|
||||
// GET /api/config defaults survive; the catalog path / in-memory list is what
|
||||
// the UI's server list actually reads.
|
||||
const catalogPath = opts.catalog ?? process.env.MCP_CATALOG_PATH;
|
||||
const hasCatalog = !!catalogPath;
|
||||
const hasConfig = !!opts.config;
|
||||
const hasHeaders = !!opts.header && Object.keys(opts.header).length > 0;
|
||||
const hasAdHocServer =
|
||||
target.length > 0 ||
|
||||
opts.serverUrl ||
|
||||
(opts.transport && opts.transport !== "stdio");
|
||||
!!opts.serverUrl ||
|
||||
(!!opts.transport && opts.transport !== "stdio");
|
||||
|
||||
// Reject illegal flag combinations up front so each branch below is clean.
|
||||
const conflict =
|
||||
hasCatalog && hasConfig
|
||||
? "--catalog and --config are mutually exclusive. --catalog is the writable catalog; --config is a read-only session file."
|
||||
: hasCatalog && hasAdHocServer
|
||||
? "--catalog cannot be combined with an ad-hoc server URL/command."
|
||||
: hasCatalog && hasHeaders
|
||||
? "--header cannot be combined with --catalog. Set per-server headers in the catalog file."
|
||||
: hasConfig && hasAdHocServer
|
||||
? "--config cannot be combined with an ad-hoc server URL/command. --config selects a read-only session file."
|
||||
: hasConfig && hasHeaders
|
||||
? "--header cannot be combined with --config. Set per-server headers inside the config file instead."
|
||||
: null;
|
||||
if (conflict) {
|
||||
console.error(`Error: ${conflict}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let initialMcpConfig: MCPServerConfig | null = null;
|
||||
let mcpConfigPath: string | undefined;
|
||||
let writable = true;
|
||||
let initialServers: MCPConfig | null = null;
|
||||
|
||||
if (hasServerInput) {
|
||||
const serverOptions: ServerConfigOptions = {
|
||||
configPath: opts.config,
|
||||
serverName: opts.server,
|
||||
target: target.length > 0 ? target : undefined,
|
||||
transport: opts.transport as "stdio" | "sse" | "http" | undefined,
|
||||
serverUrl: opts.serverUrl,
|
||||
cwd: opts.cwd,
|
||||
env: opts.e,
|
||||
};
|
||||
|
||||
if (opts.header && Object.keys(opts.header).length > 0) {
|
||||
if (hasCatalog) {
|
||||
if (opts.server) {
|
||||
console.warn(
|
||||
"Warning: --header is accepted but initial HTTP headers are configured via server settings in the web UI (post-#1358).",
|
||||
"Note: --server has no effect on the web UI yet; it lists every server in the catalog.",
|
||||
);
|
||||
}
|
||||
|
||||
// A writable catalog may not exist yet — the backend seeds it on first
|
||||
// read, exactly like the default catalog — so we don't pre-validate it.
|
||||
mcpConfigPath = resolve(process.cwd(), catalogPath!);
|
||||
writable = true;
|
||||
} else if (hasConfig) {
|
||||
if (opts.server) {
|
||||
console.warn(
|
||||
"Note: --server has no effect on the web UI yet; it lists every server in the file.",
|
||||
);
|
||||
}
|
||||
const resolvedConfigPath = resolve(process.cwd(), opts.config!);
|
||||
try {
|
||||
const configs = resolveServerConfigs(serverOptions, "single");
|
||||
const config = configs[0];
|
||||
if (!config) {
|
||||
// Validate the file loads (and exists) before serving it: a read-only
|
||||
// session never seeds, so a typo'd path would otherwise show a silently
|
||||
// empty server list instead of a clear error.
|
||||
resolveServerConfigs({ configPath: resolvedConfigPath }, "multi");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Could not load config file.";
|
||||
console.error("Error:", message);
|
||||
process.exit(1);
|
||||
}
|
||||
mcpConfigPath = resolvedConfigPath;
|
||||
writable = false;
|
||||
} else if (hasAdHocServer) {
|
||||
let config: MCPServerConfig;
|
||||
try {
|
||||
const serverOptions: ServerConfigOptions = {
|
||||
serverName: opts.server,
|
||||
target: target.length > 0 ? target : undefined,
|
||||
transport: opts.transport as "stdio" | "sse" | "http" | undefined,
|
||||
serverUrl: opts.serverUrl,
|
||||
cwd: opts.cwd,
|
||||
env: opts.e,
|
||||
};
|
||||
const resolved = resolveServerConfigs(serverOptions, "single")[0];
|
||||
if (!resolved) {
|
||||
console.error(
|
||||
"Error: Could not resolve server config. Use --config and --server, or pass a command/URL.",
|
||||
"Error: Could not resolve server config. Pass a command/URL, or use --catalog / --config to serve a file.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
initialMcpConfig = ensureStdioCwd(config);
|
||||
config = ensureStdioCwd(resolved);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Could not resolve server config.";
|
||||
console.error("Error:", message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (hasHeaders && (config.type === "stdio" || config.type === undefined)) {
|
||||
console.error(
|
||||
"Error: --header only applies to HTTP/SSE servers; stdio servers take no HTTP headers.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
initialMcpConfig = config;
|
||||
|
||||
// In-memory session entry: the SDK config plus the flat `headers` record,
|
||||
// which the backend lifts into `settings.headers` on read. Nothing is
|
||||
// written to disk — the ad-hoc server is a read-only session list.
|
||||
const entry: StoredMCPServer =
|
||||
hasHeaders && config.type !== "stdio" && config.type !== undefined
|
||||
? { ...config, headers: opts.header }
|
||||
: { ...config };
|
||||
initialServers = {
|
||||
mcpServers: { [deriveSeedServerId(config)]: entry },
|
||||
};
|
||||
writable = false;
|
||||
} else if (hasHeaders) {
|
||||
console.error(
|
||||
"Error: --header requires an ad-hoc --server-url or command target.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const webConfig = buildWebServerConfig({ initialMcpConfig });
|
||||
const webConfig = buildWebServerConfig({
|
||||
initialMcpConfig,
|
||||
mcpConfigPath,
|
||||
writable,
|
||||
initialServers,
|
||||
});
|
||||
if (!isDev) {
|
||||
webConfig.staticRoot = join(__dirname, "..", "dist");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ export async function startHonoServer(
|
|||
authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken,
|
||||
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
|
||||
storageDir: config.storageDir,
|
||||
mcpConfigPath: config.mcpConfigPath,
|
||||
writable: config.writable,
|
||||
initialServers: config.initialServers ?? undefined,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
logger: config.logger,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
authToken: config.dangerouslyOmitAuth ? undefined : config.authToken,
|
||||
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
|
||||
storageDir: config.storageDir,
|
||||
mcpConfigPath: config.mcpConfigPath,
|
||||
writable: config.writable,
|
||||
initialServers: config.initialServers ?? undefined,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
logger: config.logger,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import pino from "pino";
|
||||
import type { Logger } from "pino";
|
||||
import type { MCPServerConfig } from "../../../core/mcp/types.ts";
|
||||
import type { MCPConfig, MCPServerConfig } from "../../../core/mcp/types.ts";
|
||||
// Import directly from the leaf module — not the `core/mcp/remote/index.ts`
|
||||
// barrel — so Vite's config-time module graph doesn't pull in `createRemoteLogger`
|
||||
// (which references `pino/browser.js`) or other browser-side re-exports that
|
||||
|
|
@ -23,6 +23,28 @@ export interface WebServerConfig {
|
|||
dangerouslyOmitAuth: boolean;
|
||||
/** Single initial MCP server config, or null when no server specified. */
|
||||
initialMcpConfig: MCPServerConfig | null;
|
||||
/**
|
||||
* Absolute path to the `mcp.json` file the backend reads (and writes/watches
|
||||
* when {@link writable}). When undefined the backend falls back to the
|
||||
* default catalog (`~/.mcp-inspector/mcp.json`). Set by the launcher for
|
||||
* `--catalog <path>` (writable) or `--config <path>` (read-only session).
|
||||
* Mutually exclusive with {@link initialServers} (#1481).
|
||||
*/
|
||||
mcpConfigPath: string | undefined;
|
||||
/**
|
||||
* When false, the server list is read-only for the session: the backend
|
||||
* rejects `/api/servers` mutations and never seeds/migrates the file, and
|
||||
* the web UI hides catalog CRUD. True for the default catalog and
|
||||
* `--catalog`; false for `--config` and ad-hoc launches (#1481).
|
||||
*/
|
||||
writable: boolean;
|
||||
/**
|
||||
* In-memory server list served by the backend instead of reading a file —
|
||||
* set by the launcher to seed an ad-hoc `--server-url` / command target
|
||||
* (with `--header`) without writing any file. Implies `writable: false`.
|
||||
* Mutually exclusive with {@link mcpConfigPath} (#1481/#1483).
|
||||
*/
|
||||
initialServers: MCPConfig | null;
|
||||
storageDir: string | undefined;
|
||||
allowedOrigins: string[];
|
||||
/** Sandbox port (0 = dynamic). */
|
||||
|
|
@ -151,6 +173,12 @@ export function printServerBanner(
|
|||
|
||||
export interface BuildWebServerConfigOptions {
|
||||
initialMcpConfig?: MCPServerConfig | null;
|
||||
/** Catalog/session file the backend should serve; see {@link WebServerConfig.mcpConfigPath}. */
|
||||
mcpConfigPath?: string;
|
||||
/** Read-only when false; see {@link WebServerConfig.writable}. Defaults to true. */
|
||||
writable?: boolean;
|
||||
/** In-memory ad-hoc list; see {@link WebServerConfig.initialServers}. */
|
||||
initialServers?: MCPConfig | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -160,7 +188,12 @@ export interface BuildWebServerConfigOptions {
|
|||
export function buildWebServerConfig(
|
||||
options: BuildWebServerConfigOptions = {},
|
||||
): WebServerConfig {
|
||||
const { initialMcpConfig = null } = options;
|
||||
const {
|
||||
initialMcpConfig = null,
|
||||
mcpConfigPath,
|
||||
writable = true,
|
||||
initialServers = null,
|
||||
} = options;
|
||||
const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10);
|
||||
const hostname = process.env.HOST ?? "localhost";
|
||||
const baseUrl = `http://${hostname}:${port}`;
|
||||
|
|
@ -191,6 +224,9 @@ export function buildWebServerConfig(
|
|||
authToken,
|
||||
dangerouslyOmitAuth,
|
||||
initialMcpConfig,
|
||||
mcpConfigPath,
|
||||
writable,
|
||||
initialServers,
|
||||
storageDir: process.env.MCP_STORAGE_DIR,
|
||||
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? [
|
||||
baseUrl,
|
||||
|
|
|
|||
Loading…
Reference in a new issue