inspector/clients/web/server/vite-hono-plugin.ts
Cliff Hall 91f02fd6db
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>
2026-06-16 14:24:58 -04:00

250 lines
10 KiB
TypeScript

/**
* Vite plugin that adds Hono middleware for /api/* and the MCP Apps sandbox.
* Receives WebServerConfig only (from runner or from buildWebServerConfigFromEnv in vite.config).
*
* `apply: 'serve'` confines the plugin to `vite dev` / `vite preview` — vitest
* projects share this config but never run the dev server, so the plugin stays
* inert there.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
import open from "open";
// Direct leaf import — the `core/mcp/remote/node/index.ts` barrel re-exports
// from `../constants.js`, which would otherwise drag the broader
// `core/mcp/remote/index.ts` barrel (including `createRemoteLogger`, with its
// `pino/browser.js` import) into Vite's config-time module graph and produce
// spurious "could not resolve" warnings during build.
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
import { createSandboxController } from "./sandbox-controller.js";
import { injectAuthToken } from "./inject-auth-token.js";
import type { WebServerConfig } from "./web-server-config.js";
import {
webServerConfigToInitialPayload,
printServerBanner,
} from "./web-server-config.js";
export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
// Resolved once `configureServer` runs (createRemoteApp generates a token
// when none is supplied). Captured here so the `transformIndexHtml` hook —
// which fires per index.html request, after `configureServer` — can embed it
// into the served page. Stays "" when auth is dangerously omitted or under
// Vitest (where `configureServer` returns early), making injection a no-op.
let resolvedAuthToken = "";
return {
name: "hono-api-middleware",
// Embed the API token into the dev-served index.html so a reload at the
// bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`) still authenticates. The
// prod server applies the same injection in `server.ts`.
transformIndexHtml(html) {
return injectAuthToken(html, resolvedAuthToken);
},
// `apply: 'serve'` keeps the plugin out of `vite build`, but Vitest still
// instantiates a Vite server in middleware mode (no HTTP server) for
// transforms and invokes `configureServer` regardless. Returning early
// when `server.httpServer` is missing keeps the plugin inert in that
// context — only an actual `vite dev` (or `vite preview`) instance has
// an HTTP server to attach to.
apply: "serve",
async configureServer(server) {
// Skip the plugin entirely under Vitest. The storybook project runs
// tests in a real headless Chromium and spins up a Vite server with
// `httpServer` attached — that would otherwise pass the next guard and
// attach the Hono backend (sandbox HTTP server, banner, auto-open) for
// every test run. Component stories never hit `/api/*`, so the plugin
// brings no value to that context and only adds noise / port churn.
if (process.env.VITEST) {
return;
}
if (!server.httpServer) {
return;
}
const sandboxController = createSandboxController({
port: config.sandboxPort,
host: config.sandboxHost,
});
await sandboxController.start();
const {
app: honoApp,
authToken: resolvedToken,
close: closeApi,
} = createRemoteApp({
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,
initialConfig: webServerConfigToInitialPayload(config),
});
// Expose the resolved token to `transformIndexHtml`. Left empty when
// auth is dangerously omitted so the page carries no token global.
resolvedAuthToken = config.dangerouslyOmitAuth ? "" : resolvedToken;
// Chain the API close (mcp.json watcher) and the sandbox into the
// Vite server's close so dev-server restarts release both resources.
const originalClose = server.close.bind(server);
server.close = async () => {
await closeApi();
await sandboxController.close();
return originalClose();
};
const sandboxUrl = sandboxController.getUrl();
const logBanner = () => {
const address = server.httpServer?.address();
const actualPort =
typeof address === "object" && address !== null
? address.port
: config.port;
const url = printServerBanner(
config,
actualPort,
resolvedToken,
sandboxUrl ?? undefined,
);
if (config.autoOpen) {
open(url);
}
};
server.httpServer.once("listening", () => {
setImmediate(logBanner);
});
const honoMiddleware = async (
req: IncomingMessage,
res: ServerResponse,
next: (err?: unknown) => void,
) => {
try {
const pathname = req.url || "";
if (!pathname.startsWith("/api")) {
return next();
}
const url = `http://${req.headers.host}${pathname}`;
const headers = new Headers();
Object.entries(req.headers).forEach(
([key, value]: [string, string | string[] | undefined]) => {
if (value) {
headers.set(
key,
Array.isArray(value) ? value.join(", ") : value,
);
}
},
);
const init: RequestInit = { method: req.method, headers };
if (req.method !== "GET" && req.method !== "HEAD") {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
// Listen for `end`, `error`, AND `close`. Without `error`/`close`
// an aborted upload (browser navigates away mid-POST) leaves this
// promise pending forever, leaking memory and the connection.
// We resolve on every terminal event — partial body bytes flow on
// to Hono; the underlying fetch will fail downstream if the
// payload is truncated, which is the expected behavior on abort.
await new Promise<void>((resolve) => {
req.once("end", () => resolve());
req.once("error", () => resolve());
req.once("close", () => resolve());
});
if (chunks.length > 0) {
init.body = Buffer.concat(chunks);
}
}
const response = await honoApp.fetch(new Request(url, init));
res.statusCode = response.status;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
const isSSE = response.headers
.get("content-type")
?.includes("text/event-stream");
if (isSSE) {
res.setHeader("X-Accel-Buffering", "no");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
}
if (response.body) {
res.flushHeaders?.();
const reader = response.body.getReader();
// The browser can drop a streaming response mid-flight — an SSE
// connection closing, a navigation, or an HMR reload. Once it does,
// `res` is destroyed and further writes fail with
// ERR_STREAM_DESTROYED / EPIPE / ECONNRESET. Treat those as a
// normal end: cancel the upstream reader and stop quietly rather
// than logging a spurious error. (Cancelling resolves the awaiting
// `reader.read()`, so the pump loop exits on its own.)
const isDisconnect = (code?: string): boolean =>
code === "ERR_STREAM_DESTROYED" ||
code === "EPIPE" ||
code === "ECONNRESET";
let clientGone = false;
const onClose = (): void => {
clientGone = true;
reader.cancel().catch(() => {});
};
// Without an `error` listener a failed write becomes an uncaught
// exception; absorb benign disconnects and log anything else.
const onError = (err: Error): void => {
clientGone = true;
reader.cancel().catch(() => {});
if (!isDisconnect((err as NodeJS.ErrnoException).code)) {
console.error("[Hono Middleware] Response error:", err);
}
};
res.on("close", onClose);
res.on("error", onError);
const pump = async (): Promise<void> => {
try {
while (true) {
const { done, value } = await reader.read();
if (done || clientGone || res.destroyed) break;
// Await each write so a slow client applies backpressure
// instead of the proxy buffering the entire stream.
await new Promise<void>((resolve, reject) => {
res.write(Buffer.from(value), (err) =>
err ? reject(err) : resolve(),
);
});
}
} catch (err) {
const code = (err as NodeJS.ErrnoException | null)?.code;
if (!clientGone && !res.destroyed && !isDisconnect(code)) {
console.error("[Hono Middleware] Stream error:", err);
}
} finally {
res.off("close", onClose);
res.off("error", onError);
await reader.cancel().catch(() => {});
if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
};
void pump();
} else {
res.end();
}
} catch (error) {
next(error);
}
};
server.middlewares.use(honoMiddleware);
},
};
}