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:
parent
3569321ceb
commit
37a4d99575
3 changed files with 1636 additions and 14 deletions
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue