Port v1.5 web dev backend (vite-hono-plugin + remote-server bootstrap) (#1321)

* feat(web): port v1.5 dev backend (vite-hono-plugin + sandbox + remote-server bootstrap)

Ports the v1.5 in-process dev backend into v2 so `npm run dev` serves the
Hono remote-server (`/api/*`) alongside the Vite-served browser app. Without
this, the browser-side `createRemoteTransport` had nothing to talk to during
local development.

Adds under `clients/web/server/`:
- vite-hono-plugin.ts (Hono middleware on the Vite dev server; gated to
  `apply: 'serve'` and skipped when `process.env.VITEST` is set so it stays
  inert in vitest/storybook runs)
- sandbox-controller.ts (standalone HTTP server for the MCP Apps sandbox)
- web-server-config.ts (env parsing + initial-config payload + banner;
  `autoOpen` defaults off under VITEST so test runs don't shell out to `open`)
- server.ts (standalone Hono prod server for the future v2 launcher, #1246)
- start-vite-dev-server.ts (in-process Vite starter for the future launcher)
- vite-base-config.ts (shared optimizeDeps exclusions)

Wires `honoMiddlewarePlugin(buildWebServerConfigFromEnv())` into
`vite.config.ts` with `apply: 'serve'`. Adds the `open` dependency and a
DOM lib + `@inspector/core/*` path alias to `tsconfig.node.json` so the
dev-backend files type-check alongside vite.config.ts.

Coverage: web-server-config.ts (100% lines), sandbox-controller.ts (91% lines),
and vite-base-config.ts (100%) all clear the per-file gate. The Vite plugin,
prod server, and dev-server starter are excluded from coverage as runtime
glue exercised by `npm run dev` end-to-end (callers covered separately;
their logic lives in core/mcp/remote/node, which integration tests already
exercise).

Closes #1320.
Unblocks #1244 (Wire App.tsx) — its test plan can now run end-to-end.

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

* fix(web/server): address PR review feedback on the dev-backend port

- sandbox-controller: resolve start() with empty values on listen error
  (e.g. EADDRINUSE on a fixed MCP_SANDBOX_PORT) instead of leaving the
  promise pending forever. Without this, the Vite plugin's
  `await sandboxController.start()` could hang the entire dev backend on a
  port collision. Banner now silently omits the sandbox line; getUrl()
  keeps returning null. Adds a regression test exercising the EADDRINUSE
  path against a real socket.
- vite-hono-plugin: listen for `error` and `close` on the incoming request
  stream (in addition to `end`) so an aborted POST/PUT (browser navigates
  mid-upload) doesn't leak the middleware indefinitely.
- vite-hono-plugin: add a `.catch()` to the initial `pump()` kickoff so a
  sync throw before the first `await reader.read()` can't surface as an
  unhandled rejection.
- sandbox_proxy.html: drop the stale CSP comment — v1.5's sandbox HTTP
  server never set CSP either; this PR doesn't regress anything. Replace
  with the actual isolation model (iframe sandbox attribute + origin
  allowlist) so future readers don't assume a CSP header is enforced.
- Hoist `WebServerHandle` to a shared `server/types.ts` so the future
  launcher port (#1246) doesn't need to pick one of two duplicate
  declarations.
- Extract `resolveAutoOpen()` helper out of the triple-nested ternary in
  `buildWebServerConfigFromEnv` for legibility, with the three-way logic
  documented in one place.
- Add an inline comment explaining why
  `webServerConfigToInitialPayload` falls back to streamable-http for
  unknown discriminators (forward-compat with future SDK transports)
  rather than throwing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-18 19:22:14 -04:00 • committed by GitHub
commit 37a4d99575
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1636 additions and 14 deletions

View file

@ -8,6 +8,15 @@ This is an application for inspecting MCP servers. Has three incarnations, Web,
inspector/
├── clients/
│ ├── web/ # Web client (Vite + React + Mantine)
│ │ ├── src/ # Browser source (React app, hooks, components)
│ │ ├── server/ # Node-only dev/prod backend wiring:
│ │ │ # vite-hono-plugin.ts (Hono middleware on the Vite dev server),
│ │ │ # server.ts (standalone Hono prod server),
│ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher),
│ │ │ # web-server-config.ts (env parsing + initial-config payload + banner),
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions)
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
│ ├── cli/ # CLI client
│ ├── tui/ # TUI client
│ ├── launcher/ # Shared launcher

View file

@ -19,6 +19,7 @@
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"hono": "^4.12.18",
"open": "^10.2.0",
"pino": "^9.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@ -3280,7 +3281,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"run-applescript": "^7.0.0"
@ -3618,7 +3618,6 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bundle-name": "^4.1.0",
@ -3635,7 +3634,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@ -3648,7 +3646,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -4810,7 +4807,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
@ -4849,7 +4845,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^3.0.0"
@ -4874,7 +4869,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
"integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-inside-container": "^1.0.0"
@ -5677,7 +5671,6 @@
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"default-browser": "^5.2.1",
@ -6521,7 +6514,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@ -7652,7 +7644,6 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-wsl": "^3.1.0"

View file

@ -34,6 +34,7 @@
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"hono": "^4.12.18",
"open": "^10.2.0",
"pino": "^9.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View file

@ -0,0 +1,136 @@
/**
* Sandbox server controller: start/close and get URL.
* Used by server.ts (prod) and the Vite plugin (dev/test). Same process lifecycle as the main server.
*/
import { createServer, type Server } from "node:http";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export interface SandboxControllerOptions {
/** Port to bind (0 = dynamic). */
port: number;
/** Host to bind (default localhost). */
host?: string;
}
export interface SandboxController {
start(): Promise<{ port: number; url: string }>;
close(): Promise<void>;
getUrl(): string | null;
}
/**
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
*/
export function resolveSandboxPort(): number {
const fromSandbox = process.env.MCP_SANDBOX_PORT;
if (fromSandbox !== undefined && fromSandbox !== "") {
const n = parseInt(fromSandbox, 10);
if (!Number.isNaN(n) && n >= 0) return n;
}
const fromServer = process.env.SERVER_PORT;
if (fromServer !== undefined && fromServer !== "") {
const n = parseInt(fromServer, 10);
if (!Number.isNaN(n) && n >= 0) return n;
}
return 0;
}
export function createSandboxController(
options: SandboxControllerOptions,
): SandboxController {
const { port, host = "localhost" } = options;
let server: Server | null = null;
let sandboxUrl: string | null = null;
let sandboxHtml: string;
try {
const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html");
sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8");
} catch (e) {
sandboxHtml =
"<!DOCTYPE html><html><body>Sandbox not loaded: " +
String((e as Error).message) +
"</body></html>";
}
return {
async start(): Promise<{ port: number; url: string }> {
if (server && sandboxUrl) {
const p = parseInt(new URL(sandboxUrl).port, 10);
return { port: p, url: sandboxUrl };
}
return new Promise((resolve) => {
// Guard so a `listen` error followed by a (theoretically possible)
// late `listening` callback doesn't double-resolve the promise. The
// first signal wins.
let settled = false;
const settle = (value: { port: number; url: string }) => {
if (settled) return;
settled = true;
resolve(value);
};
server = createServer((req, res) => {
if (
req.method !== "GET" ||
(req.url !== "/sandbox" && req.url !== "/sandbox/")
) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
return;
}
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
});
res.end(sandboxHtml);
});
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`,
);
} else {
console.error("Sandbox server error:", err);
}
// Best-effort degradation: resolve with empty values rather than
// rejecting so callers (the Vite plugin in particular) keep
// attaching `/api/*`. `sandboxUrl` stays null, `getUrl()` keeps
// returning null, and the banner omits the sandbox line.
server = null;
settle({ port: 0, url: "" });
});
server.listen(port, host, () => {
const addr = server!.address();
const actualPort =
typeof addr === "object" && addr !== null && "port" in addr
? addr.port
: (addr as unknown as number);
sandboxUrl = `http://${host}:${actualPort}/sandbox`;
settle({ port: actualPort, url: sandboxUrl });
});
});
},
async close(): Promise<void> {
if (!server) return;
return new Promise((resolve) => {
server!.close(() => {
server = null;
sandboxUrl = null;
resolve();
});
});
},
getUrl(): string | null {
return sandboxUrl;
},
};
}

View file

@ -0,0 +1,147 @@
/**
* Hono production server. Export startHonoServer(config) for in-process use by the runner.
* When run as the main module (e.g. node dist/server.js), build config from env and start.
*/
import { readFileSync } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { randomBytes } from "node:crypto";
import open from "open";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
import { createSandboxController } from "./sandbox-controller.js";
import type { WebServerConfig } from "./web-server-config.js";
import {
webServerConfigToInitialPayload,
buildWebServerConfigFromEnv,
printServerBanner,
} from "./web-server-config.js";
import type { WebServerHandle } from "./types.js";
export type { WebServerHandle };
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Start the Hono production server in-process. Returns a handle that closes sandbox then HTTP server.
* Caller owns SIGINT/SIGTERM; do not register signal handlers here.
*/
export async function startHonoServer(
config: WebServerConfig,
): Promise<WebServerHandle> {
const sandboxController = createSandboxController({
port: config.sandboxPort,
host: config.sandboxHost,
});
await sandboxController.start();
const resolvedAuthToken =
config.authToken ||
(config.dangerouslyOmitAuth ? "" : randomBytes(32).toString("hex"));
const rootPath = config.staticRoot ?? __dirname;
const { app: apiApp } = createRemoteApp({
authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken,
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
storageDir: config.storageDir,
allowedOrigins: config.allowedOrigins,
sandboxUrl: sandboxController.getUrl() ?? undefined,
logger: config.logger,
initialConfig: webServerConfigToInitialPayload(config),
});
const app = new Hono();
app.use("/api/*", async (c) => {
return apiApp.fetch(c.req.raw);
});
app.get("/", async (c) => {
try {
const indexPath = join(rootPath, "index.html");
const html = readFileSync(indexPath, "utf-8");
return c.html(html);
} catch (error) {
console.error("Error serving index.html:", error);
return c.notFound();
}
});
app.use(
"/*",
serveStatic({
root: rootPath,
rewriteRequestPath: (path) => {
if (!path.includes(".") && !path.startsWith("/api")) {
return "/index.html";
}
return path;
},
}),
);
const httpServer = serve(
{
fetch: app.fetch,
port: config.port,
hostname: config.hostname,
},
(info) => {
const sandboxUrl = sandboxController.getUrl();
const url = printServerBanner(
config,
info.port,
resolvedAuthToken,
sandboxUrl ?? undefined,
);
if (config.autoOpen) {
open(url);
}
},
);
httpServer.on("error", (err: Error) => {
if (err.message.includes("EADDRINUSE")) {
console.error(
`MCP Inspector PORT IS IN USE at http://${config.hostname}:${config.port}`,
);
process.exit(1);
} else {
throw err;
}
});
return {
async close(): Promise<void> {
await sandboxController.close();
if ("closeAllConnections" in httpServer) {
httpServer.closeAllConnections();
}
await new Promise<void>((resolve, reject) => {
httpServer.close((err) => (err ? reject(err) : resolve()));
});
},
};
}
/** Run when this file is executed as the main module (e.g. node dist/server.js). */
async function runStandalone(): Promise<void> {
const config = buildWebServerConfigFromEnv();
const handle = await startHonoServer(config);
const shutdown = () => {
void handle.close().then(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
const isMain =
process.argv[1] !== undefined &&
resolve(process.argv[1]) === resolve(__filename);
if (isMain) {
void runStandalone();
}

View file

@ -0,0 +1,49 @@
/**
* Start Vite dev server in-process via the Node API. Config is passed into the plugin; no shared state.
*
* Uses the shared base config so Node-only callers don't need to load
* `vite.config.ts` (which Node can't import directly).
*/
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer, type InlineConfig } from "vite";
import react from "@vitejs/plugin-react";
import type { WebServerConfig } from "./web-server-config.js";
import { honoMiddlewarePlugin } from "./vite-hono-plugin.js";
import { getViteBaseConfig } from "./vite-base-config.js";
import type { WebServerHandle } from "./types.js";
export type { WebServerHandle };
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Start the Vite dev server in-process. Passes config into the plugin. Caller owns SIGINT/SIGTERM.
*/
export async function startViteDevServer(
config: WebServerConfig,
): Promise<WebServerHandle> {
// Canonicalize so Vite's config hash is stable and matches the deps cache.
const root = resolve(join(__dirname, ".."));
const baseConfig = getViteBaseConfig();
const inlineConfig: InlineConfig = {
...baseConfig,
configFile: false,
root,
server: {
port: config.port,
host: config.hostname,
},
plugins: [react(), honoMiddlewarePlugin(config)],
};
const server = await createServer(inlineConfig);
await server.listen();
return {
async close(): Promise<void> {
await server.close();
},
};
}

View file

@ -0,0 +1,9 @@
/**
* Shared types for the in-process web server runners (`server.ts` and
* `start-vite-dev-server.ts`). Both expose the same start/close lifecycle
* so the future v2 launcher (#1246) can treat them interchangeably.
*/
export interface WebServerHandle {
close(): Promise<void>;
}

View file

@ -0,0 +1,31 @@
/**
* Shared Vite config (build externals + optimizeDeps). Consumed by `vite.config.ts`
* and by `start-vite-dev-server.ts` so the in-process Vite starter can apply the
* same node-only exclusions without re-loading `vite.config.ts` (Node can't
* import .ts directly).
*
* Aliases and the vitest project setup live in `vite.config.ts` itself — only
* the parts needed by both the CLI runner and `vite dev` belong here.
*/
export function getViteBaseConfig() {
return {
optimizeDeps: {
// Node-only modules that the dev backend (core/mcp/remote/node/*,
// core/mcp/node/*) consumes. Excluding them from Vite's dep-pre-bundling
// step keeps `vite dev` from trying to scan/bundle them into the
// browser graph during startup.
exclude: [
"@modelcontextprotocol/sdk/client/stdio.js",
// `atomically` is reached only through `core/storage/store-io.ts`,
// which is imported by `core/mcp/remote/node/server.ts` (the Hono
// app). The module never lands in the browser graph; excluding it
// keeps Vite's dev-time scanner from chasing it through the plugin's
// node-only import chain.
"atomically",
"cross-spawn",
"which",
],
},
};
}

View file

@ -0,0 +1,198 @@
/**
* 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 type { WebServerConfig } from "./web-server-config.js";
import {
webServerConfigToInitialPayload,
printServerBanner,
} from "./web-server-config.js";
export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
return {
name: "hono-api-middleware",
// `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 originalClose = server.close.bind(server);
server.close = async () => {
await sandboxController.close();
return originalClose();
};
const { app: honoApp, authToken: resolvedToken } = createRemoteApp({
authToken: config.dangerouslyOmitAuth ? undefined : config.authToken,
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
storageDir: config.storageDir,
allowedOrigins: config.allowedOrigins,
sandboxUrl: sandboxController.getUrl() ?? undefined,
logger: config.logger,
initialConfig: webServerConfigToInitialPayload(config),
});
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();
const pump = async () => {
try {
const { done, value } = await reader.read();
if (done) {
res.end();
} else {
res.write(Buffer.from(value), (err) => {
if (err) {
console.error("[Hono Middleware] Write error:", err);
reader.cancel().catch(() => {});
res.end();
}
});
pump().catch((err) => {
console.error("[Hono Middleware] Pump error:", err);
reader.cancel().catch(() => {});
res.end();
});
}
} catch (err) {
console.error("[Hono Middleware] Read error:", err);
reader.cancel().catch(() => {});
res.end();
}
};
// The recursive pump() awaits internally, but a sync throw before
// the first `await reader.read()` (e.g. a broken reader) would
// surface as an unhandled rejection without this kickoff catch.
pump().catch((err) => {
console.error("[Hono Middleware] Initial pump error:", err);
reader.cancel().catch(() => {});
res.end();
});
} else {
res.end();
}
} catch (error) {
next(error);
}
};
server.middlewares.use(honoMiddleware);
},
};
}

View file

@ -0,0 +1,217 @@
/**
* Config object for the web server (dev and prod). Passed in-process; no env handoff.
*/
import pino from "pino";
import type { Logger } from "pino";
import type { 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
// have no business showing up in this Node-only file's import chain.
import {
API_SERVER_ENV_VARS,
LEGACY_AUTH_TOKEN_ENV,
} from "../../../core/mcp/remote/constants.ts";
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
import { resolveSandboxPort } from "./sandbox-controller.js";
export interface WebServerConfig {
port: number;
hostname: string;
authToken: string;
dangerouslyOmitAuth: boolean;
/** Single initial MCP server config, or null when no server specified. */
initialMcpConfig: MCPServerConfig | null;
storageDir: string | undefined;
allowedOrigins: string[];
/** Sandbox port (0 = dynamic). */
sandboxPort: number;
sandboxHost: string;
logger: Logger | undefined;
/** When true, open browser after server starts. */
autoOpen: boolean;
/** Root directory for static files (index.html, assets). When runner starts server in-process, pass path to dist/. */
staticRoot?: string;
}
function defaultEnvironmentFromProcess(
extra?: Record<string, string>,
): Record<string, string> {
const keys =
process.platform === "win32"
? [
"APPDATA",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"PATH",
"PROCESSOR_ARCHITECTURE",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"USERNAME",
"USERPROFILE",
"PROGRAMFILES",
]
: ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
const out: Record<string, string> = {};
for (const key of keys) {
const value = process.env[key];
if (value && !value.startsWith("()")) {
out[key] = value;
}
}
if (extra) {
Object.assign(out, extra);
}
return out;
}
/**
* Convert WebServerConfig.initialMcpConfig to the shape expected by GET /api/config.
*/
export function webServerConfigToInitialPayload(
config: WebServerConfig,
): InitialConfigPayload {
const mc = config.initialMcpConfig;
const defaultEnvironment = defaultEnvironmentFromProcess(
mc && "env" in mc && mc.env ? mc.env : undefined,
);
if (!mc) {
return { defaultEnvironment };
}
if (mc.type === "stdio" || mc.type === undefined) {
return {
defaultCommand: mc.command,
defaultArgs: mc.args ?? [],
defaultTransport: "stdio",
defaultCwd: mc.cwd,
defaultEnvironment,
};
}
if (mc.type === "sse") {
return {
defaultTransport: "sse",
defaultServerUrl: mc.url,
defaultHeaders: mc.headers ?? undefined,
defaultEnvironment,
};
}
if (mc.type === "streamable-http") {
return {
defaultTransport: "streamable-http",
defaultServerUrl: mc.url,
defaultHeaders: mc.headers ?? undefined,
defaultEnvironment,
};
}
// Forward-compat fallback: if a future SDK ships a new transport type the
// launcher hasn't taught us about yet, prefer streamable-http (the most
// permissive shape with `url` + optional `headers`) over throwing. Worst
// case the UI shows the URL the user supplied; throwing here would deny
// the user a hint at startup. Add a real branch above once the type is
// known.
const c = mc as unknown as { url: string; headers?: Record<string, string> };
return {
defaultTransport: "streamable-http",
defaultServerUrl: c.url,
defaultHeaders: c.headers,
defaultEnvironment,
};
}
export function printServerBanner(
config: WebServerConfig,
actualPort: number,
resolvedToken: string,
sandboxUrl: string | undefined,
): string {
const baseUrl = `http://${config.hostname}:${actualPort}`;
const url =
config.dangerouslyOmitAuth || !resolvedToken
? baseUrl
: `${baseUrl}?${API_SERVER_ENV_VARS.AUTH_TOKEN}=${resolvedToken}`;
console.log(`\nMCP Inspector Web is up and running at:\n ${url}\n`);
if (sandboxUrl) {
console.log(` Sandbox (MCP Apps): ${sandboxUrl}\n`);
}
if (config.dangerouslyOmitAuth) {
console.log(" Auth: disabled (DANGEROUSLY_OMIT_AUTH)\n");
} else {
console.log(` Auth token: ${resolvedToken}\n`);
}
if (config.autoOpen) {
console.log("Opening browser...");
}
return url;
}
/**
* Build WebServerConfig from process.env. Used when running server as standalone (e.g. node dist/server.js).
*/
export function buildWebServerConfigFromEnv(): WebServerConfig {
const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10);
const hostname = process.env.HOST ?? "localhost";
const baseUrl = `http://${hostname}:${port}`;
const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH;
const authToken = dangerouslyOmitAuth
? ""
: ((process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] as string | undefined) ??
(process.env[LEGACY_AUTH_TOKEN_ENV] as string | undefined) ??
"");
const initialMcpConfig: MCPServerConfig | null = null;
const sandboxPort = resolveSandboxPort();
let logger: Logger | undefined;
if (process.env.MCP_LOG_FILE) {
logger = pino(
{ level: "info" },
pino.destination({
dest: process.env.MCP_LOG_FILE,
append: true,
mkdir: true,
}),
);
}
return {
port,
hostname,
authToken,
dangerouslyOmitAuth,
initialMcpConfig,
storageDir: process.env.MCP_STORAGE_DIR,
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? [
baseUrl,
],
sandboxPort,
sandboxHost: hostname,
logger,
autoOpen: resolveAutoOpen(),
};
}
/**
* Three-way resolution for `autoOpen`:
* - explicit `MCP_AUTO_OPEN_ENABLED=true` → always open (overrides VITEST)
* - explicit `MCP_AUTO_OPEN_ENABLED=false` → never open
* - anything else → open by default UNLESS Vitest
* is running (Vitest sets `VITEST=true`; vite.config.ts is shared with
* vitest projects and the Hono plugin would otherwise shell out to
* `open()` on every test invocation).
*/
function resolveAutoOpen(): boolean {
const flag = process.env.MCP_AUTO_OPEN_ENABLED;
if (flag === "true") return true;
if (flag === "false") return false;
return !process.env.VITEST;
}

View file

@ -0,0 +1,194 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createServer, type Server } from "node:http";
import {
createSandboxController,
resolveSandboxPort,
} from "../../../../server/sandbox-controller.js";
describe("resolveSandboxPort", () => {
let envSnapshot: { mcp?: string; server?: string };
beforeEach(() => {
envSnapshot = {
mcp: process.env.MCP_SANDBOX_PORT,
server: process.env.SERVER_PORT,
};
delete process.env.MCP_SANDBOX_PORT;
delete process.env.SERVER_PORT;
});
afterEach(() => {
if (envSnapshot.mcp === undefined) delete process.env.MCP_SANDBOX_PORT;
else process.env.MCP_SANDBOX_PORT = envSnapshot.mcp;
if (envSnapshot.server === undefined) delete process.env.SERVER_PORT;
else process.env.SERVER_PORT = envSnapshot.server;
});
it("returns 0 when no env vars are set", () => {
expect(resolveSandboxPort()).toBe(0);
});
it("prefers MCP_SANDBOX_PORT over SERVER_PORT", () => {
process.env.MCP_SANDBOX_PORT = "9001";
process.env.SERVER_PORT = "9100";
expect(resolveSandboxPort()).toBe(9001);
});
it("falls back to SERVER_PORT when MCP_SANDBOX_PORT is unset", () => {
process.env.SERVER_PORT = "9100";
expect(resolveSandboxPort()).toBe(9100);
});
it("ignores non-numeric MCP_SANDBOX_PORT and falls back", () => {
process.env.MCP_SANDBOX_PORT = "garbage";
process.env.SERVER_PORT = "9100";
expect(resolveSandboxPort()).toBe(9100);
});
it("ignores empty-string MCP_SANDBOX_PORT and falls back", () => {
process.env.MCP_SANDBOX_PORT = "";
process.env.SERVER_PORT = "9100";
expect(resolveSandboxPort()).toBe(9100);
});
it("returns 0 when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => {
process.env.SERVER_PORT = "not-a-port";
expect(resolveSandboxPort()).toBe(0);
});
it("ignores empty-string SERVER_PORT", () => {
process.env.SERVER_PORT = "";
expect(resolveSandboxPort()).toBe(0);
});
it("ignores negative values", () => {
process.env.MCP_SANDBOX_PORT = "-1";
expect(resolveSandboxPort()).toBe(0);
});
});
describe("createSandboxController", () => {
it("starts on a dynamic port and serves /sandbox", async () => {
const controller = createSandboxController({ port: 0 });
try {
const { url, port } = await controller.start();
expect(port).toBeGreaterThan(0);
expect(url).toBe(`http://localhost:${port}/sandbox`);
expect(controller.getUrl()).toBe(url);
const res = await fetch(url);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html");
const body = await res.text();
// Either the real proxy file (sandbox-resource-ready) or the fallback
// "Sandbox not loaded" string, depending on whether static/ resolves.
expect(body.length).toBeGreaterThan(0);
} finally {
await controller.close();
}
});
it("returns 404 for paths other than /sandbox", async () => {
const controller = createSandboxController({ port: 0 });
try {
const { port } = await controller.start();
const res = await fetch(`http://localhost:${port}/not-here`);
expect(res.status).toBe(404);
} finally {
await controller.close();
}
});
it("returns 404 for non-GET requests", async () => {
const controller = createSandboxController({ port: 0 });
try {
const { port } = await controller.start();
const res = await fetch(`http://localhost:${port}/sandbox`, {
method: "POST",
});
expect(res.status).toBe(404);
} finally {
await controller.close();
}
});
it("treats /sandbox/ as /sandbox", async () => {
const controller = createSandboxController({ port: 0 });
try {
const { port } = await controller.start();
const res = await fetch(`http://localhost:${port}/sandbox/`);
expect(res.status).toBe(200);
} finally {
await controller.close();
}
});
it("getUrl returns null before start and null after close", async () => {
const controller = createSandboxController({ port: 0 });
expect(controller.getUrl()).toBeNull();
await controller.start();
expect(controller.getUrl()).not.toBeNull();
await controller.close();
expect(controller.getUrl()).toBeNull();
});
it("returns the cached URL when start is called twice", async () => {
const controller = createSandboxController({ port: 0 });
try {
const first = await controller.start();
const second = await controller.start();
expect(second.url).toBe(first.url);
expect(second.port).toBe(first.port);
} finally {
await controller.close();
}
});
it("close is a noop when not started", async () => {
const controller = createSandboxController({ port: 0 });
await expect(controller.close()).resolves.toBeUndefined();
});
it("honors a custom host", async () => {
const controller = createSandboxController({ port: 0, host: "127.0.0.1" });
try {
const { url } = await controller.start();
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:/);
} finally {
await controller.close();
}
});
it("resolves with empty values when listen fails (EADDRINUSE)", async () => {
// Bind a placeholder HTTP server to claim a port, then point a sandbox
// controller at the same port to force EADDRINUSE. The Vite plugin awaits
// start() in configureServer; if start() ever stops resolving, the entire
// dev backend hangs. This test pins down the resolve-on-error contract.
const blocker: Server = createServer();
await new Promise<void>((resolve) =>
blocker.listen(0, "127.0.0.1", () => resolve()),
);
const addr = blocker.address();
const port =
typeof addr === "object" && addr !== null && "port" in addr
? addr.port
: 0;
expect(port).toBeGreaterThan(0);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const controller = createSandboxController({ port, host: "127.0.0.1" });
try {
const result = await controller.start();
expect(result).toEqual({ port: 0, url: "" });
expect(controller.getUrl()).toBeNull();
// close() must be a no-op since the server never bound.
await expect(controller.close()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(`Sandbox: port ${port} in use`),
);
} finally {
errorSpy.mockRestore();
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
});

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { getViteBaseConfig } from "../../../../server/vite-base-config.js";
describe("getViteBaseConfig", () => {
it("excludes node-only deps from optimizeDeps so vite dev doesn't scan them", () => {
const config = getViteBaseConfig();
expect(config.optimizeDeps.exclude).toEqual(
expect.arrayContaining([
"@modelcontextprotocol/sdk/client/stdio.js",
"atomically",
"cross-spawn",
"which",
]),
);
});
it("returns a fresh object each call (callers can mutate safely)", () => {
const a = getViteBaseConfig();
const b = getViteBaseConfig();
expect(a).not.toBe(b);
expect(a.optimizeDeps).not.toBe(b.optimizeDeps);
});
});

View file

@ -0,0 +1,348 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
buildWebServerConfigFromEnv,
printServerBanner,
webServerConfigToInitialPayload,
type WebServerConfig,
} from "../../../../server/web-server-config.js";
import {
API_SERVER_ENV_VARS,
LEGACY_AUTH_TOKEN_ENV,
} from "../../../../../../core/mcp/remote/constants.js";
// Env keys this suite mutates. Each test starts from a snapshot taken in
// beforeEach and restores it in afterEach so neither the test runner nor
// sibling tests see leaked state.
const MUTATED_ENV_KEYS = [
API_SERVER_ENV_VARS.AUTH_TOKEN,
LEGACY_AUTH_TOKEN_ENV,
"CLIENT_PORT",
"HOST",
"DANGEROUSLY_OMIT_AUTH",
"MCP_STORAGE_DIR",
"ALLOWED_ORIGINS",
"MCP_SANDBOX_PORT",
"SERVER_PORT",
"MCP_LOG_FILE",
"MCP_AUTO_OPEN_ENABLED",
] as const;
const baseConfig = (): WebServerConfig => ({
port: 6274,
hostname: "localhost",
authToken: "tok",
dangerouslyOmitAuth: false,
initialMcpConfig: null,
storageDir: undefined,
allowedOrigins: ["http://localhost:6274"],
sandboxPort: 0,
sandboxHost: "localhost",
logger: undefined,
autoOpen: false,
});
let envSnapshot: Record<string, string | undefined>;
beforeEach(() => {
envSnapshot = {};
for (const key of MUTATED_ENV_KEYS) {
envSnapshot[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of MUTATED_ENV_KEYS) {
const original = envSnapshot[key];
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
});
describe("buildWebServerConfigFromEnv", () => {
it("returns defaults when no env vars are set", () => {
const cfg = buildWebServerConfigFromEnv();
expect(cfg.port).toBe(6274);
expect(cfg.hostname).toBe("localhost");
expect(cfg.authToken).toBe("");
expect(cfg.dangerouslyOmitAuth).toBe(false);
expect(cfg.initialMcpConfig).toBeNull();
expect(cfg.storageDir).toBeUndefined();
expect(cfg.allowedOrigins).toEqual(["http://localhost:6274"]);
expect(cfg.sandboxPort).toBe(0);
expect(cfg.sandboxHost).toBe("localhost");
expect(cfg.logger).toBeUndefined();
// Vitest sets `process.env.VITEST = 'true'`, so the autoOpen default is
// suppressed here. Real `vite dev` runs don't set VITEST and default to
// true (see "enables autoOpen by default outside Vitest" below).
expect(cfg.autoOpen).toBe(false);
});
it("honors CLIENT_PORT and HOST", () => {
process.env.CLIENT_PORT = "8123";
process.env.HOST = "0.0.0.0";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.port).toBe(8123);
expect(cfg.hostname).toBe("0.0.0.0");
expect(cfg.allowedOrigins).toEqual(["http://0.0.0.0:8123"]);
});
it("clears authToken when DANGEROUSLY_OMIT_AUTH is set even if AUTH_TOKEN is present", () => {
process.env.DANGEROUSLY_OMIT_AUTH = "1";
process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "ignored";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.dangerouslyOmitAuth).toBe(true);
expect(cfg.authToken).toBe("");
});
it("uses API_SERVER_ENV_VARS.AUTH_TOKEN when present", () => {
process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "primary";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.authToken).toBe("primary");
});
it("falls back to LEGACY_AUTH_TOKEN_ENV when the primary is unset", () => {
process.env[LEGACY_AUTH_TOKEN_ENV] = "legacy";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.authToken).toBe("legacy");
});
it("prefers API_SERVER_ENV_VARS.AUTH_TOKEN over the legacy env", () => {
process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "primary";
process.env[LEGACY_AUTH_TOKEN_ENV] = "legacy";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.authToken).toBe("primary");
});
it("parses ALLOWED_ORIGINS and filters empty entries", () => {
process.env.ALLOWED_ORIGINS = "http://a,,http://b";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.allowedOrigins).toEqual(["http://a", "http://b"]);
});
it("resolves sandboxPort from MCP_SANDBOX_PORT", () => {
process.env.MCP_SANDBOX_PORT = "9001";
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9001);
});
it("falls back to SERVER_PORT when MCP_SANDBOX_PORT is unset", () => {
process.env.SERVER_PORT = "9100";
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
});
it("ignores non-numeric MCP_SANDBOX_PORT", () => {
process.env.MCP_SANDBOX_PORT = "not-a-port";
process.env.SERVER_PORT = "9100";
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
});
it("ignores non-numeric SERVER_PORT", () => {
process.env.SERVER_PORT = "nope";
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(0);
});
it("treats MCP_SANDBOX_PORT empty string as unset", () => {
process.env.MCP_SANDBOX_PORT = "";
process.env.SERVER_PORT = "9100";
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
});
it("sets MCP_STORAGE_DIR when present", () => {
process.env.MCP_STORAGE_DIR = "/tmp/storage";
expect(buildWebServerConfigFromEnv().storageDir).toBe("/tmp/storage");
});
it("disables autoOpen when MCP_AUTO_OPEN_ENABLED is 'false'", () => {
process.env.MCP_AUTO_OPEN_ENABLED = "false";
expect(buildWebServerConfigFromEnv().autoOpen).toBe(false);
});
it("enables autoOpen when MCP_AUTO_OPEN_ENABLED is 'true'", () => {
// Explicit 'true' overrides the VITEST-suppressed default, so this works
// inside the test runner too.
process.env.MCP_AUTO_OPEN_ENABLED = "true";
expect(buildWebServerConfigFromEnv().autoOpen).toBe(true);
});
it("falls back to !VITEST when MCP_AUTO_OPEN_ENABLED is anything else", () => {
process.env.MCP_AUTO_OPEN_ENABLED = "yes";
// Vitest sets `VITEST=true` for itself; default falls to false here.
expect(buildWebServerConfigFromEnv().autoOpen).toBe(false);
});
it("enables autoOpen by default outside Vitest", () => {
const original = process.env.VITEST;
delete process.env.VITEST;
try {
expect(buildWebServerConfigFromEnv().autoOpen).toBe(true);
} finally {
if (original !== undefined) process.env.VITEST = original;
}
});
it("creates a logger when MCP_LOG_FILE is set", () => {
// pino.destination("path") opens a fd lazily; provide a writable temp path.
const logFile = `${process.env.TMPDIR ?? "/tmp"}/web-server-config.test.log`;
process.env.MCP_LOG_FILE = logFile;
const cfg = buildWebServerConfigFromEnv();
expect(cfg.logger).toBeDefined();
});
});
describe("webServerConfigToInitialPayload", () => {
it("returns only defaultEnvironment when initialMcpConfig is null", () => {
const payload = webServerConfigToInitialPayload(baseConfig());
expect(payload.defaultEnvironment).toBeDefined();
expect(payload.defaultCommand).toBeUndefined();
expect(payload.defaultTransport).toBeUndefined();
});
it("emits stdio defaults when initialMcpConfig.type === 'stdio'", () => {
const cfg = baseConfig();
cfg.initialMcpConfig = {
type: "stdio",
command: "node",
args: ["server.js"],
cwd: "/srv",
env: { FOO: "bar" },
};
const payload = webServerConfigToInitialPayload(cfg);
expect(payload.defaultTransport).toBe("stdio");
expect(payload.defaultCommand).toBe("node");
expect(payload.defaultArgs).toEqual(["server.js"]);
expect(payload.defaultCwd).toBe("/srv");
expect(payload.defaultEnvironment.FOO).toBe("bar");
});
it("treats undefined type as stdio and defaults args to []", () => {
const cfg = baseConfig();
cfg.initialMcpConfig = {
command: "echo",
} as WebServerConfig["initialMcpConfig"];
const payload = webServerConfigToInitialPayload(cfg);
expect(payload.defaultTransport).toBe("stdio");
expect(payload.defaultCommand).toBe("echo");
expect(payload.defaultArgs).toEqual([]);
});
it("emits sse defaults when initialMcpConfig.type === 'sse'", () => {
const cfg = baseConfig();
cfg.initialMcpConfig = {
type: "sse",
url: "https://srv/sse",
headers: { "x-foo": "bar" },
};
const payload = webServerConfigToInitialPayload(cfg);
expect(payload.defaultTransport).toBe("sse");
expect(payload.defaultServerUrl).toBe("https://srv/sse");
expect(payload.defaultHeaders).toEqual({ "x-foo": "bar" });
});
it("emits streamable-http defaults", () => {
const cfg = baseConfig();
cfg.initialMcpConfig = {
type: "streamable-http",
url: "https://srv/mcp",
};
const payload = webServerConfigToInitialPayload(cfg);
expect(payload.defaultTransport).toBe("streamable-http");
expect(payload.defaultServerUrl).toBe("https://srv/mcp");
expect(payload.defaultHeaders).toBeUndefined();
});
it("falls back to streamable-http when the type discriminator is unknown", () => {
const cfg = baseConfig();
// Cast through unknown to simulate an unrecognized discriminator that
// the function should still degrade gracefully on.
cfg.initialMcpConfig = {
type: "unknown",
url: "https://srv/other",
headers: { auth: "bearer" },
} as unknown as WebServerConfig["initialMcpConfig"];
const payload = webServerConfigToInitialPayload(cfg);
expect(payload.defaultTransport).toBe("streamable-http");
expect(payload.defaultServerUrl).toBe("https://srv/other");
expect(payload.defaultHeaders).toEqual({ auth: "bearer" });
});
});
describe("printServerBanner", () => {
let logSpy: ReturnType<typeof vitestSpyOnConsoleLog>;
beforeEach(() => {
logSpy = vitestSpyOnConsoleLog();
});
afterEach(() => {
logSpy.restore();
});
it("includes the auth token in the URL when auth is enabled", () => {
const url = printServerBanner(baseConfig(), 6274, "secret", undefined);
expect(url).toBe(
`http://localhost:6274?${API_SERVER_ENV_VARS.AUTH_TOKEN}=secret`,
);
expect(logSpy.lines.some((l) => l.includes("Auth token: secret"))).toBe(
true,
);
});
it("omits the query string when dangerouslyOmitAuth is true", () => {
const cfg = baseConfig();
cfg.dangerouslyOmitAuth = true;
const url = printServerBanner(cfg, 6274, "irrelevant", undefined);
expect(url).toBe("http://localhost:6274");
expect(
logSpy.lines.some((l) =>
l.includes("Auth: disabled (DANGEROUSLY_OMIT_AUTH)"),
),
).toBe(true);
});
it("omits the query string when no token is supplied", () => {
const url = printServerBanner(baseConfig(), 6274, "", undefined);
expect(url).toBe("http://localhost:6274");
});
it("prints the sandbox URL when provided", () => {
printServerBanner(baseConfig(), 6274, "tok", "http://sandbox:9999/sandbox");
expect(
logSpy.lines.some((l) =>
l.includes("Sandbox (MCP Apps): http://sandbox:9999/sandbox"),
),
).toBe(true);
});
it("logs the auto-open hint only when autoOpen is true", () => {
const noAuto = baseConfig();
printServerBanner(noAuto, 6274, "tok", undefined);
expect(logSpy.lines.some((l) => l.includes("Opening browser"))).toBe(false);
logSpy.lines.length = 0;
const withAuto = baseConfig();
withAuto.autoOpen = true;
printServerBanner(withAuto, 6274, "tok", undefined);
expect(logSpy.lines.some((l) => l.includes("Opening browser"))).toBe(true);
});
});
// Minimal console.log capture so banner-format assertions don't pollute stdout.
function vitestSpyOnConsoleLog(): {
lines: string[];
restore: () => void;
} {
const lines: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
lines.push(args.map(String).join(" "));
};
return {
lines,
restore: () => {
console.log = original;
},
};
}

View file

@ -0,0 +1,203 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<!-- Isolation model: the iframe `sandbox` attribute set on the inner
frame below ("allow-scripts allow-same-origin allow-forms") is the
only enforced isolation boundary. Plus the parent-origin allowlist
(ALLOWED_REFERRER_PATTERN + EXPECTED_HOST_ORIGIN check on incoming
messages). A CSP header is intentionally NOT set by the sandbox
controller — if you need one, send it from server.ts when serving
this file. -->
<title>MCP-UI Proxy</title>
<style>
html,
body {
margin: 0;
height: 100vh;
width: 100vw;
/* Transparent background allows parent page to show through */
background-color: transparent;
}
body {
display: flex;
flex-direction: column;
}
* {
box-sizing: border-box;
}
iframe {
background-color: transparent;
border: 0px none transparent;
padding: 0px;
overflow: hidden;
flex-grow: 1;
/* Inherit color scheme from parent for consistent transparency */
color-scheme: inherit;
}
</style>
</head>
<body>
<script>
function buildAllowAttribute(permissions) {
if (!permissions) return "";
const allowList = [];
if (permissions["camera"]) allowList.push("camera");
if (permissions["microphone"]) allowList.push("microphone");
if (permissions["geolocation"]) allowList.push("geolocation");
if (permissions["clipboardWrite"]) allowList.push("clipboard-write");
return allowList.join("; ");
}
const ALLOWED_REFERRER_PATTERN =
/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
if (window.self === window.top) {
throw new Error("This file is only to be used in an iframe sandbox.");
}
if (!document.referrer) {
throw new Error("No referrer, cannot validate embedding site.");
}
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
throw new Error(
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
);
}
// Extract the expected host origin from the referrer for origin validation.
// This is the origin we expect all parent messages to come from.
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
const OWN_ORIGIN = new URL(window.location.href).origin;
// Security self-test: verify iframe isolation is working correctly.
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
// configuration is dangerously broken and untrusted content could escape.
try {
window.top?.alert(
"If you see this, the sandbox is not setup securely.",
);
throw "FAIL";
} catch (e) {
if (e === "FAIL") {
throw new Error("The sandbox is not setup securely.");
}
// Expected: SecurityError confirms proper sandboxing.
}
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
// content. Per the specification, the Host and the Sandbox MUST have different
// origins.
const inner = document.createElement("iframe");
inner.style = "width:100%; height:100%; border:none;";
inner.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-forms",
);
// Note: allow attribute is set later when receiving sandbox-resource-ready notification
// based on the permissions requested by the app
document.body.appendChild(inner);
const RESOURCE_READY_NOTIFICATION =
"ui/notifications/sandbox-resource-ready";
const PROXY_READY_NOTIFICATION = "ui/notifications/sandbox-proxy-ready";
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
// forwarding messages between:
//
// Host (parent window) ↔ Sandbox (outer frame) ↔ View (inner iframe)
//
// Reason: the parent window and inner iframe have different origins and can't
// communicate directly, so the outer iframe forwards messages in both
// directions to connect them.
//
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
// intercepted here (not relayed) because the Sandbox uses it to configure and
// load the inner iframe with the view HTML content.
//
// Security: isolation comes from the iframe `sandbox` attribute and
// the origin allowlist above. No CSP header is set on this page (see
// the head comment); the sandbox attribute is the enforced boundary.
window.addEventListener("message", async (event) => {
if (event.source === window.parent) {
// Validate that messages from parent come from the expected host origin.
// This prevents malicious pages from sending messages to this sandbox.
if (event.origin !== EXPECTED_HOST_ORIGIN) {
console.error(
"[Sandbox] Rejecting message from unexpected origin:",
event.origin,
"expected:",
EXPECTED_HOST_ORIGIN,
);
return;
}
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, permissions } = event.data.params;
if (typeof sandbox === "string") {
inner.setAttribute("sandbox", sandbox);
}
// Set Permission Policy allow attribute if permissions are requested
const allowAttribute = buildAllowAttribute(permissions);
if (allowAttribute) {
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
inner.setAttribute("allow", allowAttribute);
}
if (typeof html === "string") {
// Use document.write instead of srcdoc (which the CesiumJS Map won't work with)
const doc =
inner.contentDocument || inner.contentWindow?.document;
if (doc) {
doc.open();
doc.write(html);
doc.close();
} else {
// Fallback to srcdoc if document is not accessible
console.warn(
"[Sandbox] document.write not available, falling back to srcdoc",
);
inner.srcdoc = html;
}
}
} else {
if (inner && inner.contentWindow) {
inner.contentWindow.postMessage(event.data, "*");
}
}
} else if (event.source === inner.contentWindow) {
if (event.origin !== OWN_ORIGIN) {
console.error(
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
event.origin,
"expected:",
OWN_ORIGIN,
);
return;
}
// Relay messages from inner frame to parent window.
// Use specific origin instead of "*" to prevent message interception.
window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
}
});
// Notify the Host that the Sandbox is ready to receive view HTML.
// Use specific origin instead of "*" to ensure only the expected host receives this.
window.parent.postMessage(
{
jsonrpc: "2.0",
method: PROXY_READY_NOTIFICATION,
params: {},
},
EXPECTED_HOST_ORIGIN,
);
</script>
</body>
</html>

View file

@ -2,7 +2,11 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
/* DOM is included so dev-backend files (and transitively core/) can use
* Web Fetch APIs (Request, Response, Headers, RequestInfo) without losing
* type info. Node 18+ provides these as globals at runtime; the DOM lib
* is the canonical source of their type declarations. */
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
@ -20,7 +24,25 @@
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
/* Path aliases. The dev-backend files imported by vite.config.ts
* (vite-hono-plugin.ts, web-server-config.ts, server.ts,
* start-vite-dev-server.ts) live in src/ and import from @inspector/core/*;
* tsc follows those transitive imports while type-checking vite.config.ts,
* so this tsconfig needs the same alias tsconfig.app.json has.
*/
"baseUrl": ".",
"paths": {
"@inspector/core/*": ["../../core/*"],
"pino": ["./node_modules/pino"],
"hono": ["./node_modules/hono"],
"hono/streaming": [
"./node_modules/hono/dist/types/helper/streaming/index"
],
"@hono/node-server": ["./node_modules/@hono/node-server"],
"atomically": ["./node_modules/atomically"]
}
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "server/**/*.ts"]
}

View file

@ -7,6 +7,9 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { honoMiddlewarePlugin } from './server/vite-hono-plugin';
import { getViteBaseConfig } from './server/vite-base-config';
import { buildWebServerConfigFromEnv } from './server/web-server-config';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(dirname, '../..');
@ -84,7 +87,36 @@ const integrationGlob = 'clients/web/src/test/integration/**/*.test.{ts,tsx}';
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
plugins: [react()],
// `honoMiddlewarePlugin` is gated by `apply: 'serve'` so it only attaches
// during `vite dev` / `vite preview` — vitest projects share this config
// but never invoke `configureServer`, so the plugin stays inert there.
plugins: [react(), honoMiddlewarePlugin(buildWebServerConfigFromEnv())],
// Shared optimizeDeps exclusions so node-only packages
// (`@modelcontextprotocol/sdk/client/stdio.js`, `cross-spawn`, `which`)
// consumed by the dev backend aren't scanned for browser pre-bundling.
// Browser code reaches the node-side stack via the Hono plugin only.
...getViteBaseConfig(),
build: {
rollupOptions: {
// Loading vite.config.ts pulls `core/mcp/remote/node/server.ts` (via the
// Hono plugin) into Rollup's module cache. That chain reaches
// `core/storage/store-io.ts`, which imports node-only `atomically`. The
// module never lands in the browser bundle (it's unreachable from
// `index.html`), but Rollup's scanner still warns about the unresolved
// import. Silence it here so the build log stays clean.
onwarn(warning, defaultHandler) {
if (
warning.code === 'UNRESOLVED_IMPORT' &&
typeof warning.message === 'string' &&
warning.message.includes("'atomically'") &&
warning.message.includes('store-io.ts')
) {
return;
}
defaultHandler(warning);
},
},
},
resolve: {
// NOTE: the unit vitest project (below) overrides this — see comment there.
alias: sharedAliases,
@ -106,6 +138,7 @@ export default defineConfig({
include: [
'src/components/**/*.{ts,tsx}',
'src/utils/**/*.{ts,tsx}',
'clients/web/server/**/*.{ts,tsx}',
path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/react/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'),
@ -118,6 +151,17 @@ export default defineConfig({
'**/*.fixtures.{ts,tsx}',
'**/index.{ts,tsx}',
'src/components/**/types.ts',
// Dev-backend runtime glue: each file is exercised end-to-end via
// `npm run dev` (Hono plugin attaches, banner prints, /api/* serves).
// `vite-hono-plugin.ts` requires standing up a real Vite server with
// an HTTP listener to drive `configureServer`; `server.ts` is the
// production Hono entry that the v2 launcher (not yet ported, #1246)
// will invoke; `start-vite-dev-server.ts` is its dev counterpart.
// The non-glue parts are extracted to `web-server-config.ts` (fully
// tested) and `sandbox-controller.ts` (HTTP behavior tested).
'clients/web/server/vite-hono-plugin.ts',
'clients/web/server/server.ts',
'clients/web/server/start-vite-dev-server.ts',
// Pure-type modules: `interface`/`type` declarations only, no runtime
// statements. Excluding them keeps the report clean (would otherwise
// surface as misleading 0/0 rows).