* feat(servers): persist per-server settings to mcp.json (#1352) - Add optional `settings` node alongside the transport config in each `~/.mcp-inspector/mcp.json` entry. Round-trips through `mcpConfigToServerEntries` / `serverEntriesToMcpConfig` and the `/api/servers` POST/PUT routes. - Drop `MCPServerConfig.headers` from `SseServerConfig` / `StreamableHttpServerConfig` and remove the headers textarea from `ServerConfigModal`. Custom headers are now entered only in `ServerSettingsForm` and reach the wire via `settings.headers`. - Wire `ServerSettingsModal` into `App.tsx`; saves dispatch through a new `useServers.updateServerSettings` mutator. `updateServer` carries the existing settings across config-only saves so they aren't dropped. - Apply settings on the *first* outbound request after a server loads — no need to open the settings form first: - `settings.headers` → SSE / streamable-http wire headers. - `settings.metadata` → `InspectorClientOptions.defaultMetadata`, merged into every outgoing `_meta` (call-time keys win). - `settings.requestTimeout` → `InspectorClientOptions.timeout`. - `settings.connectionTimeout` → Promise.race wrapper around connect(). - `settings.oauth*` → pre-seeded OAuth client credentials. - `RemoteConnectRequest` carries `settings` so the remote backend can source transport headers from the same source as the local path. - `oauthClientSecret` is persisted in `mcp.json` alongside stdio `env` values, both protected by the file's `0o600` permission. - Update `specification/v2_servers_file.md` with the new shape and the removal of the legacy headers field. Tests: round-trip in `serverList.test.ts`; persistence on POST/PUT in `servers-route.test.ts`; first-connect header injection in `transport.test.ts`; `defaultMetadata` merging in `inspectorClient.test.ts`; `useServers.updateServerSettings` + `updateServer` settings-preservation in `useServers.test.tsx`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): rebuild InspectorClient on every connect so settings refresh The previous onToggleConnection branch reused the existing InspectorClient on a same-server reconnect, which froze settings at the moment the client was first constructed. Editing headers, metadata, timeouts, or OAuth credentials in the settings form was therefore invisible until the user either switched servers or reloaded the page — surprising right after a visible save. Always call setupClientForServer on (re)connect so the latest target.settings flow through to the transport's headers / requestInit, to defaultMetadata, and to the OAuth manager. Also adds remoteClientTransport tests that pin the wire-level contract: settings round-trip through the /api/mcp/connect body when supplied, and the field is omitted when no settings are configured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(history): stop wiping pendingRequestEntries inside the connect event State managers (ManagedToolsState, ManagedPromptsState, ManagedResourcesState, ManagedResourceTemplatesState, ManagedRequestorTasksState) handle the client's "connect" event by calling `void this.refresh()`. The synchronous portion of refresh runs through SDK Protocol.request → transport.send → MessageTrackingTransport.send → trackRequest, all before yielding to the network — so each list request gets tracked into MessageLogState's pendingRequestEntries inside the same connect event dispatch. MessageLogState's own "connect" listener was registered after those state managers (it's the last one constructed in App.tsx) and cleared pendingRequestEntries on every connect. Listener order meant the clear ran *after* the requests were tracked but before their responses arrived, leaving the history with unmatched "response" entries marked PENDING. The clear-on-disconnect (via statusChange → disconnected) already handles session-boundary cleanup, so clear-on-connect was purely defensive and now actively harmful. Drop the connect listener; keep the disconnect path. Tests: updated the unit test to assert the new behavior (connect does not clear); added an integration regression that wires a sibling connect listener firing listTools and asserts the response folds into the request entry instead of appearing as an orphan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: drop accidentally tracked debugging screenshot pending-requests.png was a local debugging artifact attached to a conversation, not source. Remove it from the tree (the working copy is kept so the user still has it locally) and rely on the existing untracked behavior going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(servers): add e2e check that settings.headers reach upstream MCP Stitches the full v2 pipeline together in one assertion: browser-style RemoteClientTransport → POST /api/mcp/connect (settings in body) → backend createTransportNode applies settings.headers to requestInit → SDK StreamableHTTPClientTransport → upstream MCP test server Captures the upstream HTTP request via FetchRequestLogState and asserts both `X-Tenant` and `X-Trace` are present on the outbound POST. Catches any regression that breaks the wire between the form and the upstream server in a single integration test, instead of the previous coverage which exercised each hop in isolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ServerSettingsModal): invert ListToggle compact prop so icon matches state ListToggle's `compact` prop represents the list's *current* compact state: true means "currently compact" and renders the expand icon to advertise the action (open up). The settings modal was passing `compact={allExpanded}` — exactly inverted. When all sections were open it showed the expand icon (suggesting "open more") and when collapsed it showed the collapse icon, opposite of every other ListToggle caller in the app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(HistoryListPanel): match ServerListScreen Export button — rename + default variant Renames "Export JSON" to "Export" and switches from the subtle ToolbarButton to the same `Button variant="default"` used by ServerListControls so the two Export affordances look identical across screens. Also moves the button to the right of the ListToggle (mirroring the order on the server list screen) and disables it when there are no entries to export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(HistoryListPanel): move Clear button to top toolbar, match Export style Moves the Clear affordance out of the "History (N)" section header and up to the panel toolbar alongside Export, where it now uses the same `Button variant="default"` styling. Order in the toolbar is now: ListToggle · Clear · Export. Clear is disabled when there are no unpinned entries to remove (pinned entries are intentionally preserved). ToolbarButton helper is unused after this change and dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: ignore local debugging screenshots at repo root Stops `git add -A` from re-staging the `*.png` files Claude Code drops at the repo root from attached conversation screenshots. The pattern only matches the top-level (`/*.png`) so legitimate image assets in subdirectories (e.g. `clients/web/public/`) still get tracked when explicitly added. Also untracks the two screenshots that slipped through previously (initialize-request.png, pending-requests.png). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(LogStreamPanel): match HistoryListPanel button styling Clear / Export / Copy All on the Logging screen were rendered with the subtle ToolbarButton helper while History's equivalents use `Button variant="default"`. Switch all three to the default variant so the toolbars look identical across screens and mirror the disabling behavior — each button is now disabled when `entries.length === 0`. ToolbarButton helper is unused after this change and dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR review — debounce save, preserve-on-omit PUT, validate shape, lift connect timeout Round-up of fixes from https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4526861959: 1. ServerSettingsModal saves were firing on every keystroke (one PUT + full mcp.json refresh per character). App.tsx now debounces by 300ms and flushes on modal close so a burst of edits coalesces into one PUT and the form value can't flicker back mid-typing. 2. `PUT /api/servers/:id` with an omitted `settings` field used to silently wipe the persisted settings node. The route now treats the three intents distinctly: - field omitted → preserve existing on-disk settings - explicit null → clear the settings node - settings object → validate + apply `useServers.updateServer` is simplified to drop the client-side carry-through hack, and the test that documented the broken behavior is flipped + augmented with explicit-clear and bad-shape cases. 3. `connectionTimeout` Promise.race is moved from App.tsx into `InspectorClient.connect()`, reading `this.serverSettings?.connectionTimeout`. TUI/CLI consumers now get the same behavior; on timeout the client internally disconnects so the next connect starts clean. 4. Custom Headers hint in `ServerSettingsForm` notes that `Authorization` is owned by the OAuth flow when OAuth is configured — avoids the sharp edge where the SDK's authProvider silently overwrites a user-set Authorization header. 5. New `validateSettings` on the server route rejects malformed shapes (`settings: []`, `settings.headers: "oops"`, non-numeric timeouts, etc.) with 400 instead of letting them persist and crash the UI. 7 (nit). Renamed `settings` → `savedSettings` inside `setupClientForServer` so the variable holding the persisted settings doesn't shadow the later locally-derived values. Follow-ups opened separately: #1356 — Move oauthClientSecret out of mcp.json into the OS keychain (review #6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR review pass 2 — connect-timeout test, unmount cleanup, tagged intent, lenient settings read Pass-2 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4527089705: (a) New integration test pins the new client-layer connect-timeout contract: a hanging stub transport with `connectionTimeout: 50` rejects with the descriptive error and lands the client in "error" status. Documents what TUI/CLI consumers should expect from the lifted Promise.race in InspectorClient.connect(). (b) useEffect cleanup clears `pendingSettingsTimerRef` on unmount so a route change or HMR mid-debounce can't fire one stale PUT against an unmounted component. (c) Refactor the PUT-route `settingsIntent` discriminator from `"preserve" | "clear" | { value }` to a tagged union with `kind`, and consume it with a `switch` for exhaustiveness — reads cleaner and makes adding a fourth intent painless. (d) `normalizeMcpServers` now runs `validateSettings` on the on-disk settings node and silently strips it if invalid, mirroring the existing lenient-on-read pattern from `normalizeServerType`. Closes the read/write asymmetry where a hand-edited malformed `settings` could survive on disk and get carried through preserve-on-PUT. New route test pins the strip-on-GET + no-propagate-on-preserve-PUT behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-3 review — patch-style PUT, toast on close-flush fail Pass-3 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529276897. (b) `PUT /api/servers/:id` now treats both `config` and `settings` as optional patches: - field omitted → preserve the on-disk value - explicit body value → apply - settings: null → clear `updateServerSettings` stops reading `existing.config` from the in-memory `servers` snapshot, which closes the closure-capture hazard where a refresh between debounce-schedule and flush could silently revert a separate edit. The route is the single source of truth for transport config now. New tests cover settings-only PUT, rejected non-object config, and the empty-body no-op patch case. The previous "rejects a missing config" test is rewritten to document the new no-op-preserve semantics. (c) Inline comment near the PUT rebuild loop documents that writing the full mcpServers map back is what gives the route its desirable self-healing property for malformed settings on *other* entries — deliberate side-effect, not an accident of normalization. (d) `flushPendingSettings` surfaces failures via @mantine/notifications instead of swallowing them. The modal already closed by the time the flush PUT resolves, so a silent fail would leave the user thinking their last edits saved (especially painful for the OAuth client secret). The notifications wiring was already mounted in main.tsx. Pass-3 (a) — settings form being effectively uncontrolled mid-edit — is acknowledged in the reply: working as intended today; the proper fix is internal modal state when file-watching (#1345) forces the issue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-4 review — strip smuggled settings out of config Pass-4 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529861349. Main finding: `normalizeServerType` spreads unknown keys, so a body that nested `settings` inside `config` would smuggle a settings field onto the stored entry without ever passing through `validateSettings`. Strip the settings key off the incoming config in `buildStoredEntry` so `validateSettings` remains the single write path for the settings node — the "one source of truth" invariant pass 2 set up holds again. Two new tests pin the strip on both routes: - POST with `config.settings = { bogus }` lands an entry with no settings node on disk. - PUT with `config.settings = { bogus }` + `settings: null` clears the real settings *and* doesn't re-attach the bogus payload via the spread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-5 review — absorb late connect rejection, tighten validateSettings, log smuggle warning Pass-5 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529954490. Main finding: when the connect-time `Promise.race` lost to the timeout, the original `this.client.connect(this.transport)` promise was left pending. After `disconnect()` tore the transport down, real transports (SSE / streamable-http) would reject that promise later — with no handler attached, producing a Node `unhandledRejection` (and a vitest suite warning). Attach a no-op `.catch` right after creating the connect promise so Node sees the late rejection as handled. Nit (a): `validateSettings` was casting the raw object through to `InspectorServerSettings`, which let unknown keys ride along onto disk. Replaced the cast with an explicit pick-and-build over the known fields. Unknown stowaways now silently drop on the way through. New route test pins the behavior. Nit (b): `buildStoredEntry` now logs a `warn` via the route's pino logger when an incoming `config` carries a `settings` key. The strip is still defensive (it isn't a documented contract) but the warning lets a misconfigured client find out about the wasted field instead of debugging a silent drop. Nit (c) — the defensive `?? {}` fallback in `buildStoredEntry` — acknowledged in the review reply; left in place as belt-and-braces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-6 polish — scope absorber, thread id, drop empty OAuth, guard `in` Pass-6 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529979135. Reviewer signed off as ready; landing the four marginal cleanups since they're all small. (a) `connectPromise.catch(() => {})` moves inside the `if (connectTimeoutMs > 0)` branch. The await-only path propagates rejection cleanly through the outer try/catch — the absorber is only needed when the race leaves the original promise unattended. Code now reads as "absorber is tied to the race," which is the real invariant. (b) Thread `id` (POST id, PUT newId) through `buildStoredEntry` and include it in the smuggle warning's pino bindings. Two clients smuggling simultaneously are distinguishable in the log instead of indistinguishable. (c) `validateSettings` coerces empty-string OAuth fields to absent. The form emits `""` when the user clears an input, and an empty `oauthClientId` on disk would later be misread as "OAuth configured." New route test pins it. (d) `buildStoredEntry` adds a null/object guard before `"settings" in configObj`. The route layer pre-checks this on POST and PUT, but the helper's `unknown` parameter is a weak contract; the guard makes it safe to call from anywhere. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): reflect settings persistence + patch-PUT contract in v2_servers_file The doc was authored when `mcp.json` only stored the server list. Bring it forward to match what shipped in #1352 / PR #1353: - Summary calls out that the file also stores per-server settings and links into the Per-server settings section. - On-disk format note explains that `settings` is an optional Inspector- specific extension that other MCP tools ignore. - New top paragraph on the Per-server settings section explains the basic-config / settings UI split rationale (simpler form for the common case, advanced settings tucked behind a second dialog). - UI bullet is rewritten to match the patch-style PUT semantics that emerged across the review cycle: settings-only and config-only PUTs preserve their counterpart inside the write lock, so neither modal can silently clobber the other half. The earlier "must re-send settings on PUT" hack is gone. - New bullets capture the save cadence (300ms debounce + flush-on-close + failure toast), the patch-PUT contract (omit-preserve / null-clear / rename), and the write-path / read-path validator gates. - Secret storage bullet links to #1356 (OS-keychain follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): show a third entry with the optional settings node The On-disk format example demonstrated only basic stdio entries. Add a streamable-http entry with a populated `settings` node so readers can see the shape inline rather than having to scroll to the Per-server settings section. Inline comment in the block cross-links there for the full contract and notes that other MCP tools ignore the extension key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1029 lines
36 KiB
TypeScript
1029 lines
36 KiB
TypeScript
/**
|
|
* Hono-based remote server for MCP transports.
|
|
* Hosts /api/config, /api/mcp/connect, send, events, disconnect, /api/fetch, /api/log,
|
|
* /api/storage/:storeId, /api/servers (+ /api/servers/:id).
|
|
*/
|
|
|
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
import type pino from "pino";
|
|
import {
|
|
getDefaultStorageDir,
|
|
getDefaultMcpConfigPath,
|
|
getStoreFilePath,
|
|
validateStoreId,
|
|
readStoreFile,
|
|
writeStoreFile,
|
|
deleteStoreFile,
|
|
parseStore,
|
|
serializeStore,
|
|
} from "../../../storage/store-io.js";
|
|
import type { LogEvent } from "pino";
|
|
import { Hono } from "hono";
|
|
import type { Context, Env, Next } from "hono";
|
|
import { streamSSE } from "hono/streaming";
|
|
import { createTransportNode } from "../../node/transport.js";
|
|
import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js";
|
|
import type {
|
|
InspectorServerSettings,
|
|
MCPConfig,
|
|
MCPServerConfig,
|
|
StoredMCPServer,
|
|
} from "../../types.js";
|
|
import {
|
|
DEFAULT_SEED_CONFIG,
|
|
normalizeServerType,
|
|
} from "../../serverList.js";
|
|
import { RemoteSession } from "./remote-session.js";
|
|
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
|
|
import { API_SERVER_ENV_VARS } from "../constants.js";
|
|
|
|
/**
|
|
* Shape of the initial config returned by GET /api/config (defaults for client).
|
|
*/
|
|
export interface InitialConfigPayload {
|
|
defaultCommand?: string;
|
|
defaultArgs?: string[];
|
|
defaultTransport?: string;
|
|
defaultServerUrl?: string;
|
|
defaultCwd?: string;
|
|
defaultEnvironment: Record<string, string>;
|
|
}
|
|
|
|
export interface RemoteServerOptions {
|
|
/** Optional auth token. If not provided, uses API_SERVER_ENV_VARS.AUTH_TOKEN env var or generates one. Ignored when dangerouslyOmitAuth is true. */
|
|
authToken?: string;
|
|
|
|
/**
|
|
* When true, do not require x-mcp-remote-auth on API routes.
|
|
* Origin validation (allowedOrigins) still applies.
|
|
* Set via DANGEROUSLY_OMIT_AUTH env var; not recommended for any exposed deployment.
|
|
*/
|
|
dangerouslyOmitAuth?: boolean;
|
|
|
|
/** Optional: validate Origin header against allowed origins (for CORS) */
|
|
allowedOrigins?: string[];
|
|
|
|
/** Optional pino file logger. When set, /api/log forwards received events to it. */
|
|
logger?: pino.Logger;
|
|
|
|
/** Optional storage directory for /api/storage/:storeId. Default: ~/.mcp-inspector/storage */
|
|
storageDir?: string;
|
|
|
|
/** Optional path for the user's server list file (/api/servers). Default: ~/.mcp-inspector/mcp.json */
|
|
mcpConfigPath?: string;
|
|
|
|
/** Optional sandbox URL for MCP Apps tab. When set, GET /api/config includes sandboxUrl. */
|
|
sandboxUrl?: string;
|
|
|
|
/** Initial config for GET /api/config. Caller must pass this (e.g. from webServerConfigToInitialPayload(config)). */
|
|
initialConfig: InitialConfigPayload;
|
|
}
|
|
|
|
export interface CreateRemoteAppResult {
|
|
/** The Hono app */
|
|
app: Hono;
|
|
/** The auth token (from options, env var, or generated). Returned so caller can embed in client. */
|
|
authToken: string;
|
|
}
|
|
|
|
/**
|
|
* Hono middleware for origin validation (CORS and DNS rebinding protection).
|
|
* Validates Origin header against allowedOrigins if provided.
|
|
*/
|
|
function createOriginMiddleware(allowedOrigins?: string[]) {
|
|
return async (c: Context, next: Next) => {
|
|
// If no allowedOrigins configured, skip validation (allow all)
|
|
if (!allowedOrigins || allowedOrigins.length === 0) {
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
const origin = c.req.header("origin");
|
|
|
|
// Handle CORS preflight requests
|
|
if (c.req.method === "OPTIONS") {
|
|
if (origin && allowedOrigins.includes(origin)) {
|
|
c.header("Access-Control-Allow-Origin", origin);
|
|
c.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
c.header(
|
|
"Access-Control-Allow-Headers",
|
|
"Content-Type, x-mcp-remote-auth",
|
|
);
|
|
c.header("Access-Control-Max-Age", "86400"); // 24 hours
|
|
return c.body(null, 204);
|
|
}
|
|
// Invalid origin for preflight - return 403
|
|
return c.json(
|
|
{
|
|
error: "Forbidden",
|
|
message:
|
|
"Invalid origin. Request blocked to prevent DNS rebinding attacks.",
|
|
},
|
|
403,
|
|
);
|
|
}
|
|
|
|
// For actual requests, validate origin if present
|
|
if (origin) {
|
|
if (!allowedOrigins.includes(origin)) {
|
|
return c.json(
|
|
{
|
|
error: "Forbidden",
|
|
message:
|
|
"Invalid origin. Request blocked to prevent DNS rebinding attacks. Configure allowed origins via allowedOrigins option.",
|
|
},
|
|
403,
|
|
);
|
|
}
|
|
// Set CORS header for allowed origin
|
|
c.header("Access-Control-Allow-Origin", origin);
|
|
}
|
|
// If no origin header (same-origin or non-browser client), allow request
|
|
|
|
await next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hono middleware for auth token validation.
|
|
* Expects Bearer token format: x-mcp-remote-auth: Bearer <token>
|
|
*/
|
|
function createAuthMiddleware(authToken: string) {
|
|
return async (c: Context, next: Next) => {
|
|
const authHeader = c.req.header("x-mcp-remote-auth");
|
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
return c.json(
|
|
{
|
|
error: "Unauthorized",
|
|
message:
|
|
"Authentication required. Use the x-mcp-remote-auth header with Bearer token.",
|
|
},
|
|
401,
|
|
);
|
|
}
|
|
|
|
const providedToken = authHeader.substring(7); // Remove 'Bearer ' prefix
|
|
const expectedToken = authToken;
|
|
|
|
// Convert to buffers for timing-safe comparison
|
|
const providedBuffer = Buffer.from(providedToken);
|
|
const expectedBuffer = Buffer.from(expectedToken);
|
|
|
|
// Check length first to prevent timing attacks
|
|
if (providedBuffer.length !== expectedBuffer.length) {
|
|
return c.json(
|
|
{
|
|
error: "Unauthorized",
|
|
message:
|
|
"Authentication required. Use the x-mcp-remote-auth header with Bearer token.",
|
|
},
|
|
401,
|
|
);
|
|
}
|
|
|
|
// Perform timing-safe comparison
|
|
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
|
|
return c.json(
|
|
{
|
|
error: "Unauthorized",
|
|
message:
|
|
"Authentication required. Use the x-mcp-remote-auth header with Bearer token.",
|
|
},
|
|
401,
|
|
);
|
|
}
|
|
|
|
await next();
|
|
};
|
|
}
|
|
|
|
function forwardLogEvent(
|
|
logger: pino.Logger,
|
|
logEvent: Partial<LogEvent>,
|
|
): void {
|
|
const levelLabel = (logEvent?.level?.label ?? "info").toLowerCase();
|
|
const method = (logger as unknown as Record<string, unknown>)[levelLabel];
|
|
if (typeof method !== "function") return;
|
|
|
|
const bindings = Object.assign(
|
|
{},
|
|
...(Array.isArray(logEvent.bindings) ? logEvent.bindings : []),
|
|
);
|
|
const messages = Array.isArray(logEvent.messages) ? logEvent.messages : [];
|
|
|
|
if (messages.length === 0) {
|
|
(method as (obj: object) => void).call(logger, bindings);
|
|
return;
|
|
}
|
|
|
|
const first = messages[0];
|
|
if (typeof first === "object" && first !== null && !Array.isArray(first)) {
|
|
const obj = { ...bindings, ...(first as Record<string, unknown>) };
|
|
const msg = messages[1];
|
|
const args = messages.slice(2);
|
|
(method as (obj: object, msg?: unknown, ...args: unknown[]) => void).call(
|
|
logger,
|
|
obj,
|
|
msg,
|
|
...args,
|
|
);
|
|
} else {
|
|
const msg = messages[0];
|
|
const args = messages.slice(1);
|
|
(method as (obj: object, msg?: unknown, ...args: unknown[]) => void).call(
|
|
logger,
|
|
bindings,
|
|
msg,
|
|
...args,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function createRemoteApp(
|
|
options: RemoteServerOptions,
|
|
): CreateRemoteAppResult {
|
|
const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth;
|
|
|
|
// Determine auth token when auth is enabled: options > env var > generate
|
|
const authToken = dangerouslyOmitAuth
|
|
? ""
|
|
: options.authToken ||
|
|
process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] ||
|
|
randomBytes(32).toString("hex");
|
|
|
|
const app = new Hono<Env>();
|
|
const sessions = new Map<string, RemoteSession>();
|
|
const { logger: fileLogger, allowedOrigins } = options;
|
|
const storageDir = options.storageDir ?? getDefaultStorageDir();
|
|
const mcpConfigPath = options.mcpConfigPath ?? getDefaultMcpConfigPath();
|
|
|
|
// Apply origin validation middleware first (before auth)
|
|
// This prevents DNS rebinding attacks by validating Origin header
|
|
app.use("*", createOriginMiddleware(allowedOrigins));
|
|
|
|
// Apply auth middleware unless dangerously omitted
|
|
if (!dangerouslyOmitAuth) {
|
|
app.use("*", createAuthMiddleware(authToken));
|
|
}
|
|
|
|
app.get("/api/config", (c) => {
|
|
const payload = options.sandboxUrl
|
|
? { ...options.initialConfig, sandboxUrl: options.sandboxUrl }
|
|
: options.initialConfig;
|
|
return c.json(payload);
|
|
});
|
|
|
|
app.post("/api/mcp/connect", async (c) => {
|
|
let body: RemoteConnectRequest;
|
|
try {
|
|
body = (await c.req.json()) as RemoteConnectRequest;
|
|
} catch {
|
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
}
|
|
|
|
const config = body.config as MCPServerConfig;
|
|
if (!config) {
|
|
return c.json({ error: "Missing config" }, 400);
|
|
}
|
|
|
|
const sessionId = crypto.randomUUID();
|
|
const session = new RemoteSession(sessionId);
|
|
|
|
let transport: Awaited<ReturnType<typeof createTransportNode>>["transport"];
|
|
try {
|
|
// Create authProvider from tokens if provided
|
|
const authProvider = createTokenAuthProvider(body.oauthTokens);
|
|
|
|
const result = createTransportNode(config, {
|
|
pipeStderr: true,
|
|
onStderr: (entry) => session.onStderr(entry),
|
|
onFetchRequest: (entry) => session.onFetchRequest(entry),
|
|
authProvider,
|
|
settings: body.settings,
|
|
});
|
|
transport = result.transport;
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return c.json({ error: `Failed to create transport: ${msg}` }, 500);
|
|
}
|
|
|
|
session.setTransport(transport);
|
|
transport.onmessage = (msg) => session.onMessage(msg);
|
|
|
|
// Track if transport closes/errors during start - this matches local behavior
|
|
// If transport.start() throws, we catch it. If it resolves but transport closes immediately,
|
|
// we detect that too (process failure after spawn).
|
|
let transportFailed = false;
|
|
let transportError: string | null = null;
|
|
|
|
const originalOnclose = transport.onclose;
|
|
const originalOnerror = transport.onerror;
|
|
|
|
// Set up error handlers BEFORE calling start() so we catch failures during start
|
|
transport.onerror = (err) => {
|
|
transportFailed = true;
|
|
transportError = err instanceof Error ? err.message : String(err);
|
|
originalOnerror?.(err);
|
|
};
|
|
|
|
transport.onclose = () => {
|
|
const session = sessions.get(sessionId);
|
|
if (session) {
|
|
// Mark transport as dead but don't delete session yet
|
|
// We'll notify client via SSE and cleanup when client disconnects
|
|
const errorMsg =
|
|
transportError || "Transport closed - process may have exited";
|
|
session.markTransportDead(errorMsg);
|
|
// If no client connected yet, the client may still be about to
|
|
// open /api/mcp/events for this session — common path when the
|
|
// subprocess fails during startup, after we've already returned
|
|
// 200 with the sessionId. Hold the session (with the queued
|
|
// stderr + transport_error event) for a grace window so the
|
|
// events endpoint can drain them and surface a real error to
|
|
// the user. The endpoint cleans up on stream close; this TTL
|
|
// sweeps sessions whose client never connects at all.
|
|
if (!session.hasEventConsumer()) {
|
|
setTimeout(() => {
|
|
const stale = sessions.get(sessionId);
|
|
if (stale && !stale.hasEventConsumer()) {
|
|
sessions.delete(sessionId);
|
|
}
|
|
}, 30_000);
|
|
}
|
|
} else {
|
|
// Session not created yet - failed during start
|
|
transportFailed = true;
|
|
transportError =
|
|
transportError ||
|
|
"Transport closed during start - process may have failed";
|
|
}
|
|
originalOnclose?.();
|
|
};
|
|
|
|
try {
|
|
// transport.start() should throw if process fails to start
|
|
// If it resolves, the process should be running
|
|
await transport.start();
|
|
|
|
// Check if transport failed during start (onerror/onclose fired synchronously)
|
|
if (transportFailed) {
|
|
const errorMsg = transportError || "Transport failed during start";
|
|
return c.json({ error: `Failed to start transport: ${errorMsg}` }, 500);
|
|
}
|
|
} catch (err) {
|
|
// transport.start() threw - this is the expected failure path
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
// Preserve 401 only when the transport/SDK reports it (no message guessing)
|
|
const status =
|
|
(err as { code?: number; status?: number }).code ??
|
|
(err as { code?: number; status?: number }).status;
|
|
const is401 = status === 401;
|
|
return c.json(
|
|
{ error: `Failed to start transport: ${msg}` },
|
|
is401 ? 401 : 500,
|
|
);
|
|
}
|
|
|
|
// Transport started successfully - add to sessions
|
|
sessions.set(sessionId, session);
|
|
|
|
return c.json({ sessionId });
|
|
});
|
|
|
|
app.post("/api/mcp/send", async (c) => {
|
|
let body: RemoteSendRequest & { sessionId?: string };
|
|
try {
|
|
body = (await c.req.json()) as RemoteSendRequest & { sessionId?: string };
|
|
} catch {
|
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
}
|
|
|
|
const { sessionId, message, relatedRequestId } = body;
|
|
if (!sessionId || !message) {
|
|
return c.json({ error: "Missing sessionId or message" }, 400);
|
|
}
|
|
|
|
const session = sessions.get(sessionId);
|
|
if (!session) {
|
|
return c.json({ error: "Session not found" }, 404);
|
|
}
|
|
|
|
// Check if transport is dead - return error immediately (matches local behavior)
|
|
if (session.isTransportDead()) {
|
|
const errorMsg = session.getTransportError() || "Transport closed";
|
|
return c.json({ error: errorMsg }, 500);
|
|
}
|
|
|
|
try {
|
|
await session.transport.send(message, {
|
|
relatedRequestId: relatedRequestId as string | number | undefined,
|
|
});
|
|
return c.json({ ok: true });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
// Preserve 401 only when the transport/SDK reports it (no message guessing)
|
|
const status =
|
|
(err as { code?: number; status?: number }).code ??
|
|
(err as { code?: number; status?: number }).status;
|
|
const is401 = status === 401;
|
|
return c.json({ error: msg }, is401 ? 401 : 500);
|
|
}
|
|
});
|
|
|
|
app.get("/api/mcp/events", async (c) => {
|
|
const sessionId = c.req.query("sessionId");
|
|
if (!sessionId) {
|
|
return c.json({ error: "Missing sessionId query" }, 400);
|
|
}
|
|
|
|
const session = sessions.get(sessionId);
|
|
if (!session) {
|
|
return c.json({ error: "Session not found" }, 404);
|
|
}
|
|
|
|
return streamSSE(c, async (stream) => {
|
|
session.setEventConsumer((event) => {
|
|
const data = JSON.stringify(event);
|
|
void stream.writeSSE({
|
|
event: event.type,
|
|
data,
|
|
});
|
|
});
|
|
|
|
// Crash-on-startup path: if the subprocess died between POST
|
|
// /api/mcp/connect (which returned 200) and this GET, the session is
|
|
// alive but the transport is dead. setEventConsumer above just
|
|
// drained the queued stderr + the transport_error event. Yield once
|
|
// so the writeSSE writes flush, then return — closing the stream and
|
|
// surfacing the real error instead of a bare 404 / silent hang.
|
|
if (session.isTransportDead()) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
session.clearEventConsumer();
|
|
sessions.delete(sessionId);
|
|
return;
|
|
}
|
|
|
|
stream.onAbort(() => {
|
|
// Client disconnected - clear event consumer
|
|
const shouldCleanup = session.clearEventConsumer();
|
|
stream.close();
|
|
|
|
// If transport is dead and no client connected, cleanup session
|
|
if (shouldCleanup || session.isTransportDead()) {
|
|
sessions.delete(sessionId);
|
|
}
|
|
});
|
|
|
|
// Keep the stream open until the client disconnects. Hono's streamSSE
|
|
// closes the stream when this callback returns, so we must not return
|
|
// until the connection is aborted.
|
|
await new Promise<void>((resolve) => {
|
|
stream.onAbort(() => {
|
|
// Cleanup happens in onAbort handler above
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
app.post("/api/mcp/disconnect", async (c) => {
|
|
let body: { sessionId?: string };
|
|
try {
|
|
body = (await c.req.json()) as { sessionId?: string };
|
|
} catch {
|
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
}
|
|
|
|
const sessionId = body.sessionId;
|
|
if (!sessionId) {
|
|
return c.json({ error: "Missing sessionId" }, 400);
|
|
}
|
|
|
|
const session = sessions.get(sessionId);
|
|
if (session) {
|
|
session.clearEventConsumer();
|
|
await session.transport.close();
|
|
sessions.delete(sessionId);
|
|
}
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.post("/api/fetch", async (c) => {
|
|
let body: {
|
|
url: string;
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
body?: string;
|
|
};
|
|
try {
|
|
body = (await c.req.json()) as typeof body;
|
|
} catch {
|
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
}
|
|
|
|
const { url, method = "GET", headers = {}, body: reqBody } = body;
|
|
if (!url) {
|
|
return c.json({ error: "Missing url" }, 400);
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: new Headers(headers),
|
|
body: reqBody,
|
|
});
|
|
|
|
const resHeaders: Record<string, string> = {};
|
|
res.headers.forEach((v, k) => {
|
|
resHeaders[k] = v;
|
|
});
|
|
|
|
const contentType = res.headers.get("content-type");
|
|
const isStream =
|
|
contentType?.includes("text/event-stream") ||
|
|
contentType?.includes("application/x-ndjson");
|
|
let resBody: string | undefined;
|
|
if (!isStream && res.body) {
|
|
resBody = await res.text();
|
|
}
|
|
|
|
return c.json({
|
|
ok: res.ok,
|
|
status: res.status,
|
|
statusText: res.statusText,
|
|
headers: resHeaders,
|
|
body: resBody,
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return c.json({ error: msg }, 500);
|
|
}
|
|
});
|
|
|
|
app.post("/api/log", async (c) => {
|
|
const body = (await c.req.json().catch(() => ({}))) as Partial<LogEvent>;
|
|
if (fileLogger) {
|
|
forwardLogEvent(fileLogger, body);
|
|
}
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.get("/api/storage/:storeId", async (c) => {
|
|
const storeId = c.req.param("storeId");
|
|
if (!storeId || !validateStoreId(storeId)) {
|
|
return c.json({ error: "Invalid storeId" }, 400);
|
|
}
|
|
|
|
const filePath = getStoreFilePath(storageDir, storeId);
|
|
|
|
try {
|
|
const raw = await readStoreFile(filePath);
|
|
if (raw === null) {
|
|
return c.json({}, 200);
|
|
}
|
|
const store = parseStore(raw);
|
|
return c.json(store);
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return c.json({ error: `Failed to read store: ${msg}` }, 500);
|
|
}
|
|
});
|
|
|
|
app.post("/api/storage/:storeId", async (c) => {
|
|
const storeId = c.req.param("storeId");
|
|
if (!storeId || !validateStoreId(storeId)) {
|
|
return c.json({ error: "Invalid storeId" }, 400);
|
|
}
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = await c.req.json();
|
|
} catch {
|
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
}
|
|
|
|
const filePath = getStoreFilePath(storageDir, storeId);
|
|
|
|
try {
|
|
const jsonData = serializeStore(body);
|
|
await writeStoreFile(filePath, jsonData);
|
|
return c.json({ ok: true });
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return c.json({ error: `Failed to write store: ${msg}` }, 500);
|
|
}
|
|
});
|
|
|
|
app.delete("/api/storage/:storeId", async (c) => {
|
|
const storeId = c.req.param("storeId");
|
|
if (!storeId || !validateStoreId(storeId)) {
|
|
return c.json({ error: "Invalid storeId" }, 400);
|
|
}
|
|
|
|
const filePath = getStoreFilePath(storageDir, storeId);
|
|
|
|
try {
|
|
await deleteStoreFile(filePath);
|
|
return c.json({ ok: true });
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return c.json({ error: `Failed to delete store: ${msg}` }, 500);
|
|
}
|
|
});
|
|
|
|
// --- /api/servers (server list backed by mcp.json) ---
|
|
|
|
// Defensive normalize so editor-edited files with type:"http" or missing
|
|
// type round-trip into the canonical form on every read. Settings that
|
|
// fail `validateSettings` are silently stripped (lenient-on-read pattern
|
|
// matching `normalizeServerType`'s unknown→stdio fallback) so a malformed
|
|
// hand-edit can't propagate through preserve-on-PUT.
|
|
const normalizeMcpServers = (raw: unknown): Record<string, StoredMCPServer> => {
|
|
if (!raw || typeof raw !== "object") return {};
|
|
const out: Record<string, StoredMCPServer> = {};
|
|
for (const [id, val] of Object.entries(raw as Record<string, unknown>)) {
|
|
if (!val || typeof val !== "object") continue;
|
|
const normalized = normalizeServerType(
|
|
val as Record<string, unknown> & { type?: string },
|
|
) as StoredMCPServer;
|
|
if (normalized.settings !== undefined) {
|
|
const checked = validateSettings(normalized.settings);
|
|
if (checked.ok) {
|
|
normalized.settings = checked.value;
|
|
} else {
|
|
delete normalized.settings;
|
|
}
|
|
}
|
|
out[id] = normalized;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// Build a single on-disk entry from `{ config, settings }`, normalizing the
|
|
// type discriminator and attaching settings only when defined.
|
|
//
|
|
// `normalizeServerType` spreads unknown keys from the incoming config
|
|
// through verbatim, so a caller that included `config.settings` on the
|
|
// wire would smuggle a `settings` field straight onto the stored entry —
|
|
// bypassing `validateSettings`. Strip it here so `validateSettings`
|
|
// remains the single write path for the settings node. Log a warning if
|
|
// we observe this — it indicates a client bug (settings should travel
|
|
// through the body's top-level `settings` field, not nested in config).
|
|
//
|
|
// `id` is threaded through purely so the warning correlates to a
|
|
// specific entry; the route ultimately reaches here via POST or PUT and
|
|
// both know the target id at call time.
|
|
const buildStoredEntry = (
|
|
id: string,
|
|
config: unknown,
|
|
settings: InspectorServerSettings | undefined,
|
|
): StoredMCPServer => {
|
|
// `unknown` parameter contract: be honest about the shape rather than
|
|
// assuming the route layer's pre-checks. The route handlers do reject
|
|
// non-object config before reaching here, but this helper should stay
|
|
// safe to call from anywhere.
|
|
const configObj: Record<string, unknown> =
|
|
config !== null && typeof config === "object"
|
|
? (config as Record<string, unknown>)
|
|
: {};
|
|
if ("settings" in configObj) {
|
|
fileLogger?.warn(
|
|
{ route: "/api/servers", id, smuggledKey: "settings" },
|
|
"Stripping `settings` key from request body's `config` — settings must travel through the top-level `settings` field, not nested inside `config`.",
|
|
);
|
|
}
|
|
const { settings: _smuggled, ...configOnly } = configObj;
|
|
const normalized = normalizeServerType(
|
|
configOnly as Record<string, unknown> & { type?: string },
|
|
) as StoredMCPServer;
|
|
if (settings !== undefined) {
|
|
normalized.settings = settings;
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
// Structurally validates an InspectorServerSettings payload off the wire so
|
|
// a malformed body can't persist to disk and crash the UI later (e.g.
|
|
// `settings: []` or `settings: { headers: "oops" }`). Mirrors the lenient
|
|
// read on `normalizeMcpServers` so the only fully-trusted invariant is
|
|
// "what we accept on the write path."
|
|
const validateSettings = (
|
|
raw: unknown,
|
|
):
|
|
| { ok: true; value: InspectorServerSettings }
|
|
| { ok: false; error: string } => {
|
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return { ok: false, error: "settings must be an object" };
|
|
}
|
|
const obj = raw as Record<string, unknown>;
|
|
const isKvArray = (v: unknown): v is { key: string; value: string }[] => {
|
|
if (!Array.isArray(v)) return false;
|
|
return v.every(
|
|
(e) =>
|
|
e !== null &&
|
|
typeof e === "object" &&
|
|
typeof (e as Record<string, unknown>).key === "string" &&
|
|
typeof (e as Record<string, unknown>).value === "string",
|
|
);
|
|
};
|
|
if (!isKvArray(obj.headers)) {
|
|
return {
|
|
ok: false,
|
|
error: "settings.headers must be an array of { key, value }",
|
|
};
|
|
}
|
|
if (!isKvArray(obj.metadata)) {
|
|
return {
|
|
ok: false,
|
|
error: "settings.metadata must be an array of { key, value }",
|
|
};
|
|
}
|
|
if (
|
|
typeof obj.connectionTimeout !== "number" ||
|
|
obj.connectionTimeout < 0
|
|
) {
|
|
return {
|
|
ok: false,
|
|
error: "settings.connectionTimeout must be a non-negative number",
|
|
};
|
|
}
|
|
if (typeof obj.requestTimeout !== "number" || obj.requestTimeout < 0) {
|
|
return {
|
|
ok: false,
|
|
error: "settings.requestTimeout must be a non-negative number",
|
|
};
|
|
}
|
|
for (const optional of [
|
|
"oauthClientId",
|
|
"oauthClientSecret",
|
|
"oauthScopes",
|
|
] as const) {
|
|
if (obj[optional] !== undefined && typeof obj[optional] !== "string") {
|
|
return { ok: false, error: `settings.${optional} must be a string` };
|
|
}
|
|
}
|
|
// Build the validated value from explicitly named fields rather than
|
|
// casting the raw object through. Unknown keys silently drop so a
|
|
// misconfigured client can't smuggle stowaways onto disk, and consumers
|
|
// can rely on the validated shape being exactly InspectorServerSettings.
|
|
// Empty-string OAuth fields coerce to absent — the form emits `""` when
|
|
// the user clears an input, and an empty `oauthClientId` on disk would
|
|
// later be misread as "OAuth configured."
|
|
const value: InspectorServerSettings = {
|
|
headers: obj.headers as { key: string; value: string }[],
|
|
metadata: obj.metadata as { key: string; value: string }[],
|
|
connectionTimeout: obj.connectionTimeout as number,
|
|
requestTimeout: obj.requestTimeout as number,
|
|
};
|
|
if (typeof obj.oauthClientId === "string" && obj.oauthClientId !== "") {
|
|
value.oauthClientId = obj.oauthClientId;
|
|
}
|
|
if (
|
|
typeof obj.oauthClientSecret === "string" &&
|
|
obj.oauthClientSecret !== ""
|
|
) {
|
|
value.oauthClientSecret = obj.oauthClientSecret;
|
|
}
|
|
if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") {
|
|
value.oauthScopes = obj.oauthScopes;
|
|
}
|
|
return { ok: true, value };
|
|
};
|
|
|
|
// In-process serialization for the read-modify-write flow on the
|
|
// mutating routes (POST/PUT/DELETE). `atomically` guarantees torn-write
|
|
// safety on the file itself, but two concurrent requests can both read the
|
|
// same baseline and the second write clobbers the first. Single-user local
|
|
// dev tool, so this is a guardrail rather than a hot-path concern, but it
|
|
// avoids a real lost-update once file watching (#1345) or any remote/
|
|
// multi-client usage lands.
|
|
let writeQueue: Promise<void> = Promise.resolve();
|
|
const withWriteLock = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
const prev = writeQueue;
|
|
let release: () => void = () => {};
|
|
writeQueue = new Promise<void>((r) => {
|
|
release = r;
|
|
});
|
|
try {
|
|
await prev;
|
|
return await fn();
|
|
} finally {
|
|
release();
|
|
}
|
|
};
|
|
|
|
// Load current config from disk. ENOENT → empty. A valid-JSON file without
|
|
// `mcpServers` is treated as empty (the user may have deliberately wiped it
|
|
// or a future field arrived above the key). Invalid JSON surfaces a 500
|
|
// from the route's outer try — we'd rather flag corruption than silently
|
|
// present "no servers" and let the next write clobber the broken file.
|
|
// The seed-write on first GET happens in the route, not here, so mutating
|
|
// routes don't accidentally trigger it.
|
|
const readMcpConfig = async (): Promise<MCPConfig> => {
|
|
const raw = await readStoreFile(mcpConfigPath);
|
|
if (raw === null) return { mcpServers: {} };
|
|
const parsed = parseStore(raw) as { mcpServers?: unknown } | null;
|
|
return { mcpServers: normalizeMcpServers(parsed?.mcpServers) };
|
|
};
|
|
|
|
app.get("/api/servers", async (c) => {
|
|
try {
|
|
const raw = await readStoreFile(mcpConfigPath);
|
|
if (raw === null) {
|
|
await writeStoreFile(mcpConfigPath, serializeStore(DEFAULT_SEED_CONFIG));
|
|
return c.json(DEFAULT_SEED_CONFIG);
|
|
}
|
|
const parsed = parseStore(raw) as { mcpServers?: unknown } | null;
|
|
return c.json({ mcpServers: normalizeMcpServers(parsed?.mcpServers) });
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return c.json({ error: `Failed to read server list: ${msg}` }, 500);
|
|
}
|
|
});
|
|
|
|
app.post("/api/servers", async (c) => {
|
|
let body: { id?: unknown; config?: unknown; settings?: unknown };
|
|
try {
|
|
body = (await c.req.json()) as {
|
|
id?: unknown;
|
|
config?: unknown;
|
|
settings?: unknown;
|
|
};
|
|
} catch {
|
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
}
|
|
if (typeof body.id !== "string" || !validateStoreId(body.id)) {
|
|
return c.json(
|
|
{
|
|
error:
|
|
"Invalid id: must be non-empty and contain only alphanumeric, hyphen, or underscore",
|
|
},
|
|
400,
|
|
);
|
|
}
|
|
if (!body.config || typeof body.config !== "object") {
|
|
return c.json({ error: "Missing or invalid config" }, 400);
|
|
}
|
|
// Settings on POST: optional. `undefined` → no settings node persisted.
|
|
// Any provided value must structurally match InspectorServerSettings.
|
|
let postSettings: InspectorServerSettings | undefined;
|
|
if (body.settings !== undefined && body.settings !== null) {
|
|
const validated = validateSettings(body.settings);
|
|
if (!validated.ok) return c.json({ error: validated.error }, 400);
|
|
postSettings = validated.value;
|
|
}
|
|
const id = body.id;
|
|
|
|
try {
|
|
return await withWriteLock(async () => {
|
|
const current = await readMcpConfig();
|
|
if (id in current.mcpServers) {
|
|
return c.json({ error: `Server '${id}' already exists` }, 409);
|
|
}
|
|
current.mcpServers[id] = buildStoredEntry(
|
|
id,
|
|
body.config,
|
|
postSettings,
|
|
);
|
|
await writeStoreFile(mcpConfigPath, serializeStore(current));
|
|
return c.json({ ok: true });
|
|
});
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return c.json({ error: `Failed to add server: ${msg}` }, 500);
|
|
}
|
|
});
|
|
|
|
app.put("/api/servers/:id", async (c) => {
|
|
const originalId = c.req.param("id");
|
|
if (!originalId || !validateStoreId(originalId)) {
|
|
return c.json({ error: "Invalid id" }, 400);
|
|
}
|
|
let body: { id?: unknown; config?: unknown; settings?: unknown };
|
|
try {
|
|
body = (await c.req.json()) as {
|
|
id?: unknown;
|
|
config?: unknown;
|
|
settings?: unknown;
|
|
};
|
|
} catch {
|
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
}
|
|
// Config on PUT: optional. If the field is omitted, preserve the
|
|
// existing transport config from disk. This makes "patch only settings"
|
|
// a first-class shape — callers like updateServerSettings don't have to
|
|
// snapshot the current config off in-memory state (which could race
|
|
// against a concurrent refresh and silently revert a separate edit).
|
|
// A provided config must structurally be an object; we let
|
|
// `normalizeServerType` do the lenient type coercion downstream.
|
|
if (
|
|
body.config !== undefined &&
|
|
(body.config === null || typeof body.config !== "object")
|
|
) {
|
|
return c.json({ error: "Invalid config" }, 400);
|
|
}
|
|
// Settings on PUT have three intents:
|
|
// - field omitted (`undefined`) → preserve the existing settings node
|
|
// - explicit `null` → clear the settings node
|
|
// - a settings object → validate and apply
|
|
// Preserving on omission means callers that only want to update config
|
|
// (e.g. ServerConfigModal save) don't silently wipe persisted settings.
|
|
type SettingsIntent =
|
|
| { kind: "preserve" }
|
|
| { kind: "clear" }
|
|
| { kind: "apply"; value: InspectorServerSettings };
|
|
let settingsIntent: SettingsIntent;
|
|
if (body.settings === undefined) {
|
|
settingsIntent = { kind: "preserve" };
|
|
} else if (body.settings === null) {
|
|
settingsIntent = { kind: "clear" };
|
|
} else {
|
|
const validated = validateSettings(body.settings);
|
|
if (!validated.ok) return c.json({ error: validated.error }, 400);
|
|
settingsIntent = { kind: "apply", value: validated.value };
|
|
}
|
|
const newId = typeof body.id === "string" ? body.id : originalId;
|
|
if (!validateStoreId(newId)) {
|
|
return c.json({ error: "Invalid new id" }, 400);
|
|
}
|
|
|
|
try {
|
|
return await withWriteLock(async () => {
|
|
const current = await readMcpConfig();
|
|
if (!(originalId in current.mcpServers)) {
|
|
return c.json({ error: `Server '${originalId}' not found` }, 404);
|
|
}
|
|
if (newId !== originalId && newId in current.mcpServers) {
|
|
return c.json({ error: `Server '${newId}' already exists` }, 409);
|
|
}
|
|
// Rebuild preserving insertion order; replace the original key in
|
|
// place so the file diff stays minimal when not renaming. Writing
|
|
// the full map back also means normalize-on-read self-heals any
|
|
// malformed settings node on *other* servers in the file — a
|
|
// deliberate side-effect of using `readMcpConfig` + full rewrite
|
|
// here.
|
|
const existing = current.mcpServers[originalId]!;
|
|
// Split the existing entry into its config (everything except
|
|
// settings) and its settings, then apply patch semantics from the
|
|
// body to each.
|
|
const { settings: _existingSettings, ...existingConfig } = existing;
|
|
const nextConfig =
|
|
body.config !== undefined ? body.config : existingConfig;
|
|
let nextSettings: InspectorServerSettings | undefined;
|
|
switch (settingsIntent.kind) {
|
|
case "preserve":
|
|
nextSettings = existing.settings;
|
|
break;
|
|
case "clear":
|
|
nextSettings = undefined;
|
|
break;
|
|
case "apply":
|
|
nextSettings = settingsIntent.value;
|
|
break;
|
|
}
|
|
const next: MCPConfig = { mcpServers: {} };
|
|
for (const [key, val] of Object.entries(current.mcpServers)) {
|
|
if (key === originalId) {
|
|
next.mcpServers[newId] = buildStoredEntry(
|
|
newId,
|
|
nextConfig,
|
|
nextSettings,
|
|
);
|
|
} else {
|
|
next.mcpServers[key] = val;
|
|
}
|
|
}
|
|
await writeStoreFile(mcpConfigPath, serializeStore(next));
|
|
return c.json({ ok: true });
|
|
});
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return c.json({ error: `Failed to update server: ${msg}` }, 500);
|
|
}
|
|
});
|
|
|
|
app.delete("/api/servers/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
if (!id || !validateStoreId(id)) {
|
|
return c.json({ error: "Invalid id" }, 400);
|
|
}
|
|
|
|
try {
|
|
return await withWriteLock(async () => {
|
|
const current = await readMcpConfig();
|
|
if (!(id in current.mcpServers)) {
|
|
return c.json({ ok: true });
|
|
}
|
|
delete current.mcpServers[id];
|
|
await writeStoreFile(mcpConfigPath, serializeStore(current));
|
|
return c.json({ ok: true });
|
|
});
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
return c.json({ error: `Failed to delete server: ${msg}` }, 500);
|
|
}
|
|
});
|
|
|
|
return { app, authToken };
|
|
}
|