inspector/clients/web/server/web-server-config.ts
Cliff Hall 37a4d99575
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>
2026-05-18 19:22:14 -04:00

217 lines
6.4 KiB
TypeScript

/**
* 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;
}