Skip to content

Commit 37a4d99

Browse files
cliffhallclaude
andauthored
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>
1 parent 3569321 commit 37a4d99

16 files changed

Lines changed: 1636 additions & 14 deletions

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ This is an application for inspecting MCP servers. Has three incarnations, Web,
88
inspector/
99
├── clients/
1010
│ ├── web/ # Web client (Vite + React + Mantine)
11+
│ │ ├── src/ # Browser source (React app, hooks, components)
12+
│ │ ├── server/ # Node-only dev/prod backend wiring:
13+
│ │ │ # vite-hono-plugin.ts (Hono middleware on the Vite dev server),
14+
│ │ │ # server.ts (standalone Hono prod server),
15+
│ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher),
16+
│ │ │ # web-server-config.ts (env parsing + initial-config payload + banner),
17+
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
18+
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions)
19+
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
1120
│ ├── cli/ # CLI client
1221
│ ├── tui/ # TUI client
1322
│ ├── launcher/ # Shared launcher

clients/web/package-lock.json

Lines changed: 1 addition & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clients/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"ajv": "^8.17.1",
3535
"atomically": "^2.1.1",
3636
"hono": "^4.12.18",
37+
"open": "^10.2.0",
3738
"pino": "^9.14.0",
3839
"react": "^19.2.4",
3940
"react-dom": "^19.2.4",
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Sandbox server controller: start/close and get URL.
3+
* Used by server.ts (prod) and the Vite plugin (dev/test). Same process lifecycle as the main server.
4+
*/
5+
6+
import { createServer, type Server } from "node:http";
7+
import { readFileSync } from "node:fs";
8+
import { dirname, join } from "node:path";
9+
import { fileURLToPath } from "node:url";
10+
11+
const __dirname = dirname(fileURLToPath(import.meta.url));
12+
13+
export interface SandboxControllerOptions {
14+
/** Port to bind (0 = dynamic). */
15+
port: number;
16+
/** Host to bind (default localhost). */
17+
host?: string;
18+
}
19+
20+
export interface SandboxController {
21+
start(): Promise<{ port: number; url: string }>;
22+
close(): Promise<void>;
23+
getUrl(): string | null;
24+
}
25+
26+
/**
27+
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
28+
*/
29+
export function resolveSandboxPort(): number {
30+
const fromSandbox = process.env.MCP_SANDBOX_PORT;
31+
if (fromSandbox !== undefined && fromSandbox !== "") {
32+
const n = parseInt(fromSandbox, 10);
33+
if (!Number.isNaN(n) && n >= 0) return n;
34+
}
35+
const fromServer = process.env.SERVER_PORT;
36+
if (fromServer !== undefined && fromServer !== "") {
37+
const n = parseInt(fromServer, 10);
38+
if (!Number.isNaN(n) && n >= 0) return n;
39+
}
40+
return 0;
41+
}
42+
43+
export function createSandboxController(
44+
options: SandboxControllerOptions,
45+
): SandboxController {
46+
const { port, host = "localhost" } = options;
47+
let server: Server | null = null;
48+
let sandboxUrl: string | null = null;
49+
50+
let sandboxHtml: string;
51+
try {
52+
const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html");
53+
sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8");
54+
} catch (e) {
55+
sandboxHtml =
56+
"<!DOCTYPE html><html><body>Sandbox not loaded: " +
57+
String((e as Error).message) +
58+
"</body></html>";
59+
}
60+
61+
return {
62+
async start(): Promise<{ port: number; url: string }> {
63+
if (server && sandboxUrl) {
64+
const p = parseInt(new URL(sandboxUrl).port, 10);
65+
return { port: p, url: sandboxUrl };
66+
}
67+
return new Promise((resolve) => {
68+
// Guard so a `listen` error followed by a (theoretically possible)
69+
// late `listening` callback doesn't double-resolve the promise. The
70+
// first signal wins.
71+
let settled = false;
72+
const settle = (value: { port: number; url: string }) => {
73+
if (settled) return;
74+
settled = true;
75+
resolve(value);
76+
};
77+
78+
server = createServer((req, res) => {
79+
if (
80+
req.method !== "GET" ||
81+
(req.url !== "/sandbox" && req.url !== "/sandbox/")
82+
) {
83+
res.writeHead(404, { "Content-Type": "text/plain" });
84+
res.end("Not Found");
85+
return;
86+
}
87+
res.writeHead(200, {
88+
"Content-Type": "text/html; charset=utf-8",
89+
"Cache-Control": "no-store, no-cache, must-revalidate",
90+
Pragma: "no-cache",
91+
});
92+
res.end(sandboxHtml);
93+
});
94+
server.on("error", (err: NodeJS.ErrnoException) => {
95+
if (err.code === "EADDRINUSE") {
96+
console.error(
97+
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`,
98+
);
99+
} else {
100+
console.error("Sandbox server error:", err);
101+
}
102+
// Best-effort degradation: resolve with empty values rather than
103+
// rejecting so callers (the Vite plugin in particular) keep
104+
// attaching `/api/*`. `sandboxUrl` stays null, `getUrl()` keeps
105+
// returning null, and the banner omits the sandbox line.
106+
server = null;
107+
settle({ port: 0, url: "" });
108+
});
109+
server.listen(port, host, () => {
110+
const addr = server!.address();
111+
const actualPort =
112+
typeof addr === "object" && addr !== null && "port" in addr
113+
? addr.port
114+
: (addr as unknown as number);
115+
sandboxUrl = `http://${host}:${actualPort}/sandbox`;
116+
settle({ port: actualPort, url: sandboxUrl });
117+
});
118+
});
119+
},
120+
121+
async close(): Promise<void> {
122+
if (!server) return;
123+
return new Promise((resolve) => {
124+
server!.close(() => {
125+
server = null;
126+
sandboxUrl = null;
127+
resolve();
128+
});
129+
});
130+
},
131+
132+
getUrl(): string | null {
133+
return sandboxUrl;
134+
},
135+
};
136+
}

clients/web/server/server.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Hono production server. Export startHonoServer(config) for in-process use by the runner.
3+
* When run as the main module (e.g. node dist/server.js), build config from env and start.
4+
*/
5+
6+
import { readFileSync } from "node:fs";
7+
import { join, dirname, resolve } from "node:path";
8+
import { fileURLToPath } from "node:url";
9+
import { randomBytes } from "node:crypto";
10+
import open from "open";
11+
import { serve } from "@hono/node-server";
12+
import { serveStatic } from "@hono/node-server/serve-static";
13+
import { Hono } from "hono";
14+
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
15+
import { createSandboxController } from "./sandbox-controller.js";
16+
import type { WebServerConfig } from "./web-server-config.js";
17+
import {
18+
webServerConfigToInitialPayload,
19+
buildWebServerConfigFromEnv,
20+
printServerBanner,
21+
} from "./web-server-config.js";
22+
import type { WebServerHandle } from "./types.js";
23+
24+
export type { WebServerHandle };
25+
26+
const __filename = fileURLToPath(import.meta.url);
27+
const __dirname = dirname(__filename);
28+
29+
/**
30+
* Start the Hono production server in-process. Returns a handle that closes sandbox then HTTP server.
31+
* Caller owns SIGINT/SIGTERM; do not register signal handlers here.
32+
*/
33+
export async function startHonoServer(
34+
config: WebServerConfig,
35+
): Promise<WebServerHandle> {
36+
const sandboxController = createSandboxController({
37+
port: config.sandboxPort,
38+
host: config.sandboxHost,
39+
});
40+
await sandboxController.start();
41+
42+
const resolvedAuthToken =
43+
config.authToken ||
44+
(config.dangerouslyOmitAuth ? "" : randomBytes(32).toString("hex"));
45+
46+
const rootPath = config.staticRoot ?? __dirname;
47+
48+
const { app: apiApp } = createRemoteApp({
49+
authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken,
50+
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
51+
storageDir: config.storageDir,
52+
allowedOrigins: config.allowedOrigins,
53+
sandboxUrl: sandboxController.getUrl() ?? undefined,
54+
logger: config.logger,
55+
initialConfig: webServerConfigToInitialPayload(config),
56+
});
57+
58+
const app = new Hono();
59+
app.use("/api/*", async (c) => {
60+
return apiApp.fetch(c.req.raw);
61+
});
62+
63+
app.get("/", async (c) => {
64+
try {
65+
const indexPath = join(rootPath, "index.html");
66+
const html = readFileSync(indexPath, "utf-8");
67+
return c.html(html);
68+
} catch (error) {
69+
console.error("Error serving index.html:", error);
70+
return c.notFound();
71+
}
72+
});
73+
74+
app.use(
75+
"/*",
76+
serveStatic({
77+
root: rootPath,
78+
rewriteRequestPath: (path) => {
79+
if (!path.includes(".") && !path.startsWith("/api")) {
80+
return "/index.html";
81+
}
82+
return path;
83+
},
84+
}),
85+
);
86+
87+
const httpServer = serve(
88+
{
89+
fetch: app.fetch,
90+
port: config.port,
91+
hostname: config.hostname,
92+
},
93+
(info) => {
94+
const sandboxUrl = sandboxController.getUrl();
95+
const url = printServerBanner(
96+
config,
97+
info.port,
98+
resolvedAuthToken,
99+
sandboxUrl ?? undefined,
100+
);
101+
if (config.autoOpen) {
102+
open(url);
103+
}
104+
},
105+
);
106+
107+
httpServer.on("error", (err: Error) => {
108+
if (err.message.includes("EADDRINUSE")) {
109+
console.error(
110+
`MCP Inspector PORT IS IN USE at http://${config.hostname}:${config.port}`,
111+
);
112+
process.exit(1);
113+
} else {
114+
throw err;
115+
}
116+
});
117+
118+
return {
119+
async close(): Promise<void> {
120+
await sandboxController.close();
121+
if ("closeAllConnections" in httpServer) {
122+
httpServer.closeAllConnections();
123+
}
124+
await new Promise<void>((resolve, reject) => {
125+
httpServer.close((err) => (err ? reject(err) : resolve()));
126+
});
127+
},
128+
};
129+
}
130+
131+
/** Run when this file is executed as the main module (e.g. node dist/server.js). */
132+
async function runStandalone(): Promise<void> {
133+
const config = buildWebServerConfigFromEnv();
134+
const handle = await startHonoServer(config);
135+
const shutdown = () => {
136+
void handle.close().then(() => process.exit(0));
137+
};
138+
process.on("SIGINT", shutdown);
139+
process.on("SIGTERM", shutdown);
140+
}
141+
142+
const isMain =
143+
process.argv[1] !== undefined &&
144+
resolve(process.argv[1]) === resolve(__filename);
145+
if (isMain) {
146+
void runStandalone();
147+
}

0 commit comments

Comments
 (0)