feat(web): build dist on demand for prod --web + end-to-end CI smoke (#1486) (#1497)

* feat(web): build dist on demand for prod --web + end-to-end CI smoke (#1486)

Prod `mcp-inspector --web` serves static assets from clients/web/dist,
which only exists after a build. In a fresh checkout it was absent and the
prod path served a broken page (ENOENT on index.html); the launcher smoke
test only covered --help, so this path was unverified in CI.

- ensure-web-build.ts: build dist on demand (vite build via build:client)
  when missing, or exit with an actionable error if the build can't run.
- run-web.ts: invoke ensureWebBuild before startHonoServer in the prod path
  (--dev runs Vite directly and skips it).
- scripts/smoke-web.mjs + smoke:web: start --web against built dist and
  assert GET / returns 200 with the injected __INSPECTOR_API_TOKEN__.
  Wired into validate:launcher and a new CI step.
- Docs: launcher README + AGENTS.md.

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

* fix(web): surface spawn error in build-on-demand; clarify smoke comment

Address PR #1497 review:
- runViteBuild now logs result.error.message (e.g. ENOENT when npm isn't on
  PATH) before returning a null status, so the spawn-failure cause surfaces
  instead of only the generic actionable error. The build seam gains a `log`
  param; added a test covering the ENOENT path.
- Reword scripts/smoke-web.mjs header so it no longer implies the readiness
  wait covers a cold build-on-demand vite build (it's sized for a pre-built
  dist, which validate/CI guarantee).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-16 18:34:52 -04:00 • committed by GitHub
commit 36b48e61a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 405 additions and 11 deletions

View file

@ -55,6 +55,13 @@ jobs:
node clients/launcher/build/index.js --cli --help
node clients/launcher/build/index.js --tui --help
- name: Smoke test prod web launcher (end-to-end)
# Starts `mcp-inspector --web` against the built clients/web/dist (from
# the "Run Build" step) and asserts GET / serves the SPA (HTTP 200) with
# the injected auth token — the prod web path the --help smoke can't
# reach (#1486).
run: npm run smoke:web
- name: Cache Playwright browsers
uses: actions/cache@v4
with:

View file

@ -134,7 +134,8 @@ All work should be driven by items on the project board.
### Lint-fixed, Formatted code
- ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
- ALWAYS do `npm run validate` before pushing any changes — from the repo root it mirrors what CI enforces across every client: web `format:check` + `lint` + `build` + `test:coverage` (`validate:web`), then the CLI and TUI test suites (`test:cli` / `test:tui`), then a build of CLI + TUI + launcher and the launcher `--help` smoke (`validate:launcher`). The sub-scripts (`validate:web`, `validate:launcher`, `smoke:launcher`) can be run individually when iterating on one client. Storybook is the only CI step left out (see below).
- ALWAYS do `npm run validate` before pushing any changes — from the repo root it mirrors what CI enforces across every client: web `format:check` + `lint` + `build` + `test:coverage` (`validate:web`), then the CLI and TUI test suites (`test:cli` / `test:tui`), then a build of CLI + TUI + launcher, the launcher `--help` smoke (`smoke:launcher`), and the prod web end-to-end smoke (`smoke:web`) — together `validate:launcher`. The sub-scripts (`validate:web`, `validate:launcher`, `smoke:launcher`, `smoke:web`) can be run individually when iterating on one client. Storybook is the only CI step left out (see below).
- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`.
- Also run `npm run test:storybook` before pushing — it executes every story's `play` function in headless Chromium via `@vitest/browser-playwright` (~10s). CI runs this as a separate step after the unit/lint/build checks; failures block merge. It is kept out of `validate` because it needs the Playwright browser binary and is much slower than the unit suite.
### Typescript instructions

View file

@ -26,6 +26,22 @@ Rules: `--catalog` and `--config` are mutually exclusive; neither combines with
an ad-hoc target or `--header`; `--header` requires an ad-hoc HTTP/SSE server
and is applied to that connection (it is no longer a warn-only no-op).
### Production web build (`--web`, no `--dev`)
Prod `--web` serves static assets from `clients/web/dist/`, which only exists
after a build. In the published package `dist/` always ships. In a fresh dev
checkout it is absent, so the runner **builds it on demand** the first time you
launch (`vite build` via `npm run build:client`, run in `clients/web`) instead
of serving a broken page (#1486). If that build can't run — e.g. dev
dependencies are missing — the launcher exits with an actionable error telling
you to run `npm run build` (from `clients/web`) or relaunch with `--dev` to use
the Vite dev server. `--dev` never needs `dist/`; it runs Vite directly.
CI and `npm run validate` (via `validate:launcher`) exercise this prod path
end-to-end with `npm run smoke:web` (`scripts/smoke-web.mjs`): it starts
`mcp-inspector --web` against the built `dist/` and asserts `GET /` returns the
SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`.
## Publishing
The root `@modelcontextprotocol/inspector` package ships as one fat tarball: `npm run build` at the repo root builds all clients, then `prepack` runs before `npm publish`. Runtime dependencies are declared on the root `package.json`; client builds bundle `@inspector/core` and externalize npm packages resolved from the root install.

View file

@ -13,6 +13,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build && npm run build:runner",
"build:client": "vite build",
"build:runner": "tsup --config tsup.runner.config.ts",
"lint": "eslint .",
"format": "prettier --write src",

View file

@ -0,0 +1,76 @@
/**
* Ensure the production web assets exist before the Hono server tries to serve
* them (#1486).
*
* `mcp-inspector --web` (prod, no `--dev`) serves static files from
* `clients/web/dist/`, which only exists after a build. In the published package
* `dist/` always ships, so this is a no-op there. In a fresh dev checkout the
* directory is absent and the prod path would otherwise serve a broken page
* (`ENOENT` on `index.html`). When the build is missing we build it once, on
* demand; if that build can't run (e.g. dev dependencies are absent, as in a
* stripped published install) we throw an actionable error instead of letting
* the server come up and serve a broken page.
*/
import { existsSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
/** Seams for testing — defaults hit the real filesystem / child process. */
export interface EnsureWebBuildDeps {
exists?: (path: string) => boolean;
/** Build the web assets in `webRoot`; returns the child exit code (null if it never ran). */
build?: (webRoot: string, log: (message: string) => void) => number | null;
log?: (message: string) => void;
}
function runViteBuild(
webRoot: string,
log: (message: string) => void,
): number | null {
const result = spawnSync("npm", ["run", "build:client"], {
cwd: webRoot,
stdio: "inherit",
// npm resolves to npm.cmd on Windows, which needs a shell to be spawnable.
shell: process.platform === "win32",
});
// A spawn failure (e.g. `npm` not on PATH → ENOENT) leaves `status` null and
// the child produces no output of its own, so surface the cause before the
// caller falls back to the generic actionable error.
if (result.error) {
log(`Web build failed to start: ${result.error.message}`);
}
return result.status;
}
/**
* Build `clients/web/dist` on demand when it is missing so `--web` always has
* something to serve. `webRoot` is the web client root (where `package.json` and
* the `build:client` script live); `distRoot` is the static-asset directory the
* Hono server serves from. Throws an actionable error if the build can't be
* produced — the caller surfaces the message and exits non-zero.
*/
export function ensureWebBuild(
webRoot: string,
distRoot: string,
deps: EnsureWebBuildDeps = {},
): void {
const exists = deps.exists ?? existsSync;
const build = deps.build ?? runViteBuild;
const log = deps.log ?? ((message: string) => console.log(message));
const indexHtml = join(distRoot, "index.html");
if (exists(indexHtml)) return;
log(
"No production web build found at clients/web/dist. Building the web UI now (first run only; this can take a minute)...",
);
const status = build(webRoot, log);
if (status !== 0 || !exists(indexHtml)) {
throw new Error(
"Could not build the web UI automatically. Build it manually with `npm run build` (run from clients/web), or launch with `--dev` to use the Vite dev server.",
);
}
}

View file

@ -21,6 +21,7 @@ import {
import { buildWebServerConfig } from "./web-server-config.js";
import { startViteDevServer } from "./start-vite-dev-server.js";
import { startHonoServer } from "./server.js";
import { ensureWebBuild } from "./ensure-web-build.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -237,8 +238,10 @@ export async function runWeb(argv: string[]): Promise<number> {
writable,
initialServers,
});
const webRoot = join(__dirname, "..");
const distRoot = join(webRoot, "dist");
if (!isDev) {
webConfig.staticRoot = join(__dirname, "..", "dist");
webConfig.staticRoot = distRoot;
}
console.log(
@ -253,6 +256,9 @@ export async function runWeb(argv: string[]): Promise<number> {
if (isDev) {
handle = await startViteDevServer(webConfig);
} else {
// Build clients/web/dist on demand if it's missing so prod `--web` never
// serves a broken page (#1486); throws an actionable error if it can't.
ensureWebBuild(webRoot, distRoot);
handle = await startHonoServer(webConfig);
}
} catch (err) {

View file

@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { join } from "node:path";
const { existsSync, spawnSync } = vi.hoisted(() => ({
existsSync: vi.fn(),
spawnSync: vi.fn(),
}));
vi.mock("node:fs", () => ({ existsSync }));
vi.mock("node:child_process", () => ({ spawnSync }));
import { ensureWebBuild } from "../../../../server/ensure-web-build.js";
const WEB_ROOT = "/repo/clients/web";
const DIST_ROOT = join(WEB_ROOT, "dist");
const INDEX_HTML = join(DIST_ROOT, "index.html");
describe("ensureWebBuild", () => {
it("is a no-op when the build already exists", () => {
const exists = vi.fn().mockReturnValue(true);
const build = vi.fn();
const log = vi.fn();
ensureWebBuild(WEB_ROOT, DIST_ROOT, { exists, build, log });
expect(exists).toHaveBeenCalledWith(INDEX_HTML);
expect(build).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
});
it("builds on demand when the build is missing, then proceeds", () => {
// Missing before the build, present after.
const exists = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
const build = vi.fn().mockReturnValue(0);
const log = vi.fn();
expect(() =>
ensureWebBuild(WEB_ROOT, DIST_ROOT, { exists, build, log }),
).not.toThrow();
expect(build).toHaveBeenCalledWith(WEB_ROOT, expect.any(Function));
expect(log).toHaveBeenCalledTimes(1);
expect(log.mock.calls[0]?.[0]).toContain("No production web build found");
});
it("throws an actionable error when the build exits non-zero", () => {
const exists = vi.fn().mockReturnValue(false);
const build = vi.fn().mockReturnValue(1);
expect(() =>
ensureWebBuild(WEB_ROOT, DIST_ROOT, { exists, build, log: vi.fn() }),
).toThrow(/Could not build the web UI automatically/);
});
it("throws when the build can't even run (null exit status)", () => {
const exists = vi.fn().mockReturnValue(false);
const build = vi.fn().mockReturnValue(null);
expect(() =>
ensureWebBuild(WEB_ROOT, DIST_ROOT, { exists, build, log: vi.fn() }),
).toThrow(/`--dev`/);
});
it("throws when the build succeeds but still produces no index.html", () => {
// Exit 0 but the asset is still absent (e.g. a partial/aborted build).
const exists = vi.fn().mockReturnValue(false);
const build = vi.fn().mockReturnValue(0);
expect(() =>
ensureWebBuild(WEB_ROOT, DIST_ROOT, { exists, build, log: vi.fn() }),
).toThrow(/Could not build the web UI/);
expect(build).toHaveBeenCalledTimes(1);
});
// Default seams (real `existsSync` / `spawnSync` / `console.log`) so the
// production code path — not just the injected one — is exercised.
describe("with default dependencies", () => {
beforeEach(() => {
existsSync.mockReset();
spawnSync.mockReset();
});
it("is a no-op when index.html already exists (no spawn)", () => {
existsSync.mockReturnValue(true);
ensureWebBuild(WEB_ROOT, DIST_ROOT);
expect(existsSync).toHaveBeenCalledWith(INDEX_HTML);
expect(spawnSync).not.toHaveBeenCalled();
});
it("spawns `npm run build:client` in the web root when missing", () => {
existsSync.mockReturnValueOnce(false).mockReturnValueOnce(true);
spawnSync.mockReturnValue({ status: 0 });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
ensureWebBuild(WEB_ROOT, DIST_ROOT);
} finally {
logSpy.mockRestore();
}
expect(spawnSync).toHaveBeenCalledWith(
"npm",
["run", "build:client"],
expect.objectContaining({ cwd: WEB_ROOT, stdio: "inherit" }),
);
});
it("throws when the spawned build exits non-zero", () => {
existsSync.mockReturnValue(false);
spawnSync.mockReturnValue({ status: 1 });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
expect(() => ensureWebBuild(WEB_ROOT, DIST_ROOT)).toThrow(
/Could not build the web UI automatically/,
);
} finally {
logSpy.mockRestore();
}
});
it("surfaces the spawn error when the build can't start (ENOENT)", () => {
existsSync.mockReturnValue(false);
spawnSync.mockReturnValue({
status: null,
error: new Error("spawn npm ENOENT"),
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
expect(() => ensureWebBuild(WEB_ROOT, DIST_ROOT)).toThrow(
/Could not build the web UI/,
);
expect(
logSpy.mock.calls.some((c) =>
String(c[0]).includes(
"Web build failed to start: spawn npm ENOENT",
),
),
).toBe(true);
} finally {
logSpy.mockRestore();
}
});
});
});

View file

@ -4,14 +4,18 @@ import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const { mockClose, startViteDevServer, startHonoServer } = vi.hoisted(() => {
const mockClose = vi.fn().mockResolvedValue(undefined);
return {
mockClose,
startViteDevServer: vi.fn().mockResolvedValue({ close: mockClose }),
startHonoServer: vi.fn().mockResolvedValue({ close: mockClose }),
};
});
const { mockClose, startViteDevServer, startHonoServer, ensureWebBuild } =
vi.hoisted(() => {
const mockClose = vi.fn().mockResolvedValue(undefined);
return {
mockClose,
startViteDevServer: vi.fn().mockResolvedValue({ close: mockClose }),
startHonoServer: vi.fn().mockResolvedValue({ close: mockClose }),
// No-op by default so prod tests don't shell out to a real build; the
// build-on-demand contract is covered in ensure-web-build.test.ts.
ensureWebBuild: vi.fn(),
};
});
vi.mock("../../../../server/start-vite-dev-server.js", () => ({
startViteDevServer,
@ -21,6 +25,10 @@ vi.mock("../../../../server/server.js", () => ({
startHonoServer,
}));
vi.mock("../../../../server/ensure-web-build.js", () => ({
ensureWebBuild,
}));
import * as nodeConfig from "../../../../../../core/mcp/node/config.js";
import { runWeb } from "../../../../server/run-web.js";
@ -39,6 +47,8 @@ describe("runWeb", () => {
beforeEach(() => {
startViteDevServer.mockClear();
startHonoServer.mockClear();
ensureWebBuild.mockClear();
ensureWebBuild.mockReset();
mockClose.mockClear();
signalHandlers.clear();
exitCode = undefined;
@ -126,6 +136,32 @@ describe("runWeb", () => {
);
});
it("ensures the web build before starting Hono in production", async () => {
void runWeb(["node", "run-web"]);
await expectServerStarted(startHonoServer);
expect(ensureWebBuild).toHaveBeenCalledTimes(1);
// (webRoot, distRoot) — both end at the web client root / its dist dir.
const [webRoot, distRoot] = ensureWebBuild.mock.calls[0] as [
string,
string,
];
expect(distRoot).toMatch(/dist$/);
expect(distRoot.startsWith(webRoot)).toBe(true);
});
it("exits with an actionable error when the web build can't be produced", async () => {
ensureWebBuild.mockImplementationOnce(() => {
throw new Error("Could not build the web UI automatically.");
});
await expect(runWeb(["node", "run-web"])).rejects.toThrow("process.exit:1");
expect(
errorLines.some((l) => l.includes("Could not build the web UI")),
).toBe(true);
expect(startHonoServer).not.toHaveBeenCalled();
});
it("starts Vite when --dev is set", async () => {
void runWeb(["node", "run-web", "--dev"]);
await expectServerStarted(startViteDevServer);
@ -134,6 +170,8 @@ describe("runWeb", () => {
expect.objectContaining({ initialMcpConfig: null, writable: true }),
);
expect(logLines.some((l) => l.includes("development mode"))).toBe(true);
// --dev uses Vite directly; no static build is required or attempted.
expect(ensureWebBuild).not.toHaveBeenCalled();
});
// --- ad-hoc launch → read-only in-memory session ------------------------

View file

@ -39,8 +39,9 @@
"test:tui": "cd clients/tui && npm run test",
"validate": "npm run validate:web && npm run test:cli && npm run test:tui && npm run validate:launcher",
"validate:web": "cd clients/web && npm run validate",
"validate:launcher": "npm run build:cli && npm run build:tui && npm run build:launcher && npm run smoke:launcher",
"validate:launcher": "npm run build:cli && npm run build:tui && npm run build:launcher && npm run smoke:launcher && npm run smoke:web",
"smoke:launcher": "node clients/launcher/build/index.js --help && node clients/launcher/build/index.js --cli --help && node clients/launcher/build/index.js --tui --help",
"smoke:web": "node scripts/smoke-web.mjs",
"test": "cd ./clients/web/ && npm run test && cd ../cli && npm run test && cd ../tui && npm run test",
"test:coverage": "cd ./clients/web/ && npm run test:coverage",
"test:storybook": "cd ./clients/web/ && npm run test:storybook",

100
scripts/smoke-web.mjs Normal file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* End-to-end smoke test for the prod web launcher path (#1486).
*
* `npm run smoke:launcher` only checks `--help` for each mode; it never starts
* the prod web server, so the static-asset / injected-token path went unverified
* in CI. This script launches `mcp-inspector --web` (prod, no `--dev`) against
* the built `clients/web/dist`, waits for it to listen, and asserts that `/`
* returns HTTP 200 with the injected auth-token global. It exits non-zero
* (failing CI / `npm run validate`) on any failure, then shuts the server down.
*
* Expects `clients/web/dist` and `clients/launcher/build` to be built first —
* the validate / CI ordering guarantees this, so the readiness wait below is
* sized for an already-built `dist`. (If `dist` happened to be missing, the
* launcher's build-on-demand path would build it on startup, which a cold
* `vite build` could push past that wait — not a scenario this script targets.)
*/
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const HOST = "127.0.0.1";
const PORT = process.env.SMOKE_WEB_PORT ?? "6299";
const TOKEN = "smoke-web-token";
// Mirrors INSPECTOR_API_TOKEN_GLOBAL in core/mcp/remote/constants.ts; kept as a
// literal because this plain .mjs script can't import the TS source.
const TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__";
const BASE_URL = `http://${HOST}:${PORT}`;
const child = spawn(
process.execPath,
["clients/launcher/build/index.js", "--web"],
{
env: {
...process.env,
CLIENT_PORT: PORT,
HOST,
MCP_INSPECTOR_API_TOKEN: TOKEN,
// Don't pop a browser in CI.
MCP_AUTO_OPEN_ENABLED: "false",
},
stdio: ["ignore", "inherit", "inherit"],
},
);
let exited = false;
let exitCode = null;
child.on("exit", (code) => {
exited = true;
exitCode = code;
});
function shutdown() {
if (!exited) child.kill("SIGTERM");
}
function fail(message) {
console.error(`smoke:web FAILED — ${message}`);
shutdown();
process.exit(1);
}
async function fetchRoot() {
for (let attempt = 0; attempt < 120; attempt++) {
if (exited) {
throw new Error(
`launcher exited (code ${exitCode}) before serving — see output above`,
);
}
try {
return await fetch(`${BASE_URL}/`);
} catch {
await delay(500);
}
}
throw new Error("server did not start within 60s");
}
try {
const res = await fetchRoot();
if (res.status !== 200) {
fail(`GET / returned HTTP ${res.status}, expected 200`);
}
const body = await res.text();
if (!body.includes(TOKEN_GLOBAL)) {
fail(
`served HTML is missing the ${TOKEN_GLOBAL} global (token not injected)`,
);
}
if (!body.includes(TOKEN)) {
fail("served HTML is missing the injected auth-token value");
}
console.log(
`smoke:web OK — GET / => 200 with injected ${TOKEN_GLOBAL} at ${BASE_URL}`,
);
shutdown();
process.exit(0);
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}