inspector/clients/web/src/test/integration/server/web-server-config.test.ts
cliffhall adde9ccf94 fix: bind loopback by address and pin the sandbox port so containers work
Two defaults that each break the Inspector inside a container.

#1951 — the web UI never loads in a VS Code dev container. `HOST` defaulted
to the NAME `localhost`, and `server.listen(port, host)` resolves a name
through `dns.lookup` and binds the SINGLE address it gets back. Since Node 17
the default result order is `verbatim`, so on a glibc Linux host — whose
/etc/hosts maps localhost to both 127.0.0.1 and ::1 — that is `::1`. The
server bound IPv6 loopback only and refused every IPv4 client, including the
dev container port forwarder. It presents as an endless spinner rather than a
connection error because the forwarder accepts on the host side before failing
to connect inward. Default to the ADDRESS 127.0.0.1 instead; an explicit
HOST=localhost is still honored as typed.

#2008 — the Apps tab is unreachable in any container. The MCP Apps sandbox
listened on an OS-assigned port, so it changed every run and could never be
named in a devcontainer `forwardPorts`, a `docker run -p`, or a tunnel. Default
it to a fixed 6275, mirroring CLIENT_PORT's fixed 6274, and retry once on an
OS-assigned port (loudly) if it is taken — the collision a dynamic default
never had.

Verified in the reporter's exact image
(mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie): both listeners
come up on 127.0.0.1 and answer 200 on 6274 and 6275/sandbox.

Also EXPOSE 6275 and set MCP_SANDBOX_PORT in the image, and document both
defaults in the web README, the migration guide, and the root README's Docker
section.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 12:33:27 -04:00

689 lines
24 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
buildWebServerConfig,
buildWebServerConfigFromEnv,
defaultAllowedOrigins,
printServerBanner,
webServerConfigToInitialPayload,
type WebServerConfig,
} from "../../../../server/web-server-config.js";
import { DEFAULT_SANDBOX_PORT } from "../../../../server/sandbox-controller.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",
"DANGEROUSLY_BIND_ALL_INTERFACES",
"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: "127.0.0.1",
authToken: "tok",
dangerouslyOmitAuth: false,
initialMcpConfig: null,
mcpConfigPath: undefined,
writable: true,
initialServers: null,
storageDir: undefined,
allowedOrigins: ["http://localhost:6274"],
sandboxPort: 0,
sandboxHost: "127.0.0.1",
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("127.0.0.1");
expect(cfg.authToken).toBe("");
expect(cfg.dangerouslyOmitAuth).toBe(false);
expect(cfg.initialMcpConfig).toBeNull();
expect(cfg.storageDir).toBeUndefined();
expect(cfg.allowedOrigins).toEqual([
"http://localhost:6274",
"http://127.0.0.1:6274",
"http://[::1]:6274",
]);
expect(cfg.sandboxPort).toBe(DEFAULT_SANDBOX_PORT);
expect(cfg.sandboxHost).toBe("127.0.0.1");
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 a loopback HOST", () => {
process.env.CLIENT_PORT = "8123";
process.env.HOST = "127.0.0.1";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.port).toBe(8123);
expect(cfg.hostname).toBe("127.0.0.1");
});
it("refuses HOST=0.0.0.0 without the bind-all opt-in", () => {
process.env.HOST = "0.0.0.0";
expect(() => buildWebServerConfigFromEnv()).toThrow(
/DANGEROUSLY_BIND_ALL_INTERFACES/,
);
});
it("allows HOST=0.0.0.0 when the bind-all opt-in is set", () => {
process.env.CLIENT_PORT = "8123";
process.env.HOST = "0.0.0.0";
process.env.DANGEROUSLY_BIND_ALL_INTERFACES = "true";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.hostname).toBe("0.0.0.0");
// A wildcard bind serves loopback, so the default allow-list is the
// loopback trio (what a `docker run -p` browser actually sends) plus the
// canonical wildcard pair (0.0.0.0 / [::]).
expect(cfg.allowedOrigins).toEqual([
"http://localhost:8123",
"http://127.0.0.1:8123",
"http://[::1]:8123",
"http://0.0.0.0:8123",
"http://[::]:8123",
]);
});
it("expands a loopback HOST into all equivalent loopback origins", () => {
// `localhost` resolves to either 127.0.0.1 or ::1 depending on the OS, and
// Node/Vite may bind the IPv6 form — so a browser can send `Origin:
// http://[::1]:PORT` even though the banner advertised `localhost`. The
// default must accept all three so the DNS-rebinding guard doesn't 403 a
// legitimate loopback connect (the exact bug behind an stdio server that
// "should always work" failing with a 403 Invalid origin).
process.env.HOST = "127.0.0.1";
process.env.CLIENT_PORT = "6274";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.allowedOrigins).toEqual([
"http://localhost:6274",
"http://127.0.0.1:6274",
"http://[::1]:6274",
]);
});
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, trimming entries and filtering empties", () => {
process.env.ALLOWED_ORIGINS = "http://a:1, , http://b:2 ";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.allowedOrigins).toEqual(["http://a:1", "http://b:2"]);
});
it.each(["", " ", ","])(
"falls back to the default (not an empty allow-all) when ALLOWED_ORIGINS is %j",
(value) => {
// An empty parsed list must NOT reach the middleware — it treats an empty
// allow-list as allow-all, which would silently disable the origin guard.
process.env.ALLOWED_ORIGINS = value;
const cfg = buildWebServerConfigFromEnv();
expect(cfg.allowedOrigins).toEqual([
"http://localhost:6274",
"http://127.0.0.1:6274",
"http://[::1]:6274",
]);
},
);
it("canonicalizes ALLOWED_ORIGINS entries so copy-paste forms still match", () => {
// Trailing slash, uppercase host, explicit default :80 — all normalized to
// the canonical Origin the browser actually sends.
process.env.ALLOWED_ORIGINS =
"http://localhost:6274/, http://Example.COM:6274, http://myhost:80";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.allowedOrigins).toEqual([
"http://localhost:6274",
"http://example.com:6274",
"http://myhost",
]);
});
it("drops unparseable ALLOWED_ORIGINS entries (and falls back if none survive)", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
process.env.ALLOWED_ORIGINS = "not a url, *, http://ok:1";
const cfg = buildWebServerConfigFromEnv();
// "not a url" / "*" throw; "http://ok:1" parses.
expect(cfg.allowedOrigins).toEqual(["http://ok:1"]);
process.env.ALLOWED_ORIGINS = "not a url, also bad";
const cfg2 = buildWebServerConfigFromEnv();
expect(cfg2.allowedOrigins).toEqual([
"http://localhost:6274",
"http://127.0.0.1:6274",
"http://[::1]:6274",
]);
} finally {
warnSpy.mockRestore();
}
});
it('drops scheme-less / opaque ALLOWED_ORIGINS entries rather than allow-listing "null"', () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
// `new URL("localhost:6274").origin` is the literal "null" (not a throw) —
// must be dropped, not allow-listed (it'd match a real `Origin: null`).
process.env.ALLOWED_ORIGINS =
"localhost:6274, file:///srv, http://real:1";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.allowedOrigins).toEqual(["http://real:1"]);
// All scheme-less → nothing survives → fail-closed fallback to default.
process.env.ALLOWED_ORIGINS = "localhost:6274, myhost:8080";
const cfg2 = buildWebServerConfigFromEnv();
expect(cfg2.allowedOrigins).toEqual([
"http://localhost:6274",
"http://127.0.0.1:6274",
"http://[::1]:6274",
]);
} finally {
warnSpy.mockRestore();
}
});
it("drops a wildcard ALLOWED_ORIGINS entry (works for CSP but never for the origin check)", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
process.env.ALLOWED_ORIGINS =
"http://*.example.com:6274, http://real.example.com:6274";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.allowedOrigins).toEqual(["http://real.example.com:6274"]);
} finally {
warnSpy.mockRestore();
}
});
it.each(["0", "abc", "70000", "-1", "6274abc", "80.9"])(
"rejects an unusable CLIENT_PORT %j with an actionable error",
(value) => {
process.env.CLIENT_PORT = value;
expect(() => buildWebServerConfigFromEnv()).toThrow(/CLIENT_PORT/);
},
);
it("treats an empty CLIENT_PORT as unset (defaults to 6274)", () => {
process.env.CLIENT_PORT = "";
const cfg = buildWebServerConfigFromEnv();
expect(cfg.port).toBe(6274);
});
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(
DEFAULT_SANDBOX_PORT,
);
});
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("defaultAllowedOrigins", () => {
it.each(["localhost", "127.0.0.1", "::1", "[::1]", "LOCALHOST"])(
"expands the loopback host %s into all three loopback origins",
(host) => {
expect(defaultAllowedOrigins(host, 6274)).toEqual([
"http://localhost:6274",
"http://127.0.0.1:6274",
"http://[::1]:6274",
]);
},
);
it("returns a single exact origin for a specific non-loopback host", () => {
expect(defaultAllowedOrigins("192.168.1.50", 6274)).toEqual([
"http://192.168.1.50:6274",
]);
});
// A non-canonical spelling of a loopback address is canonicalized (the way the
// browser canonicalizes it into `Origin`), so it's recognized as loopback and
// gets the trio — not a single unmatchable entry.
it.each([
"127.1",
"0x7f.0.0.1",
"2130706433",
"0:0:0:0:0:0:0:1",
"::0001",
"::ffff:127.0.0.1", // IPv4-mapped loopback — the socket answers on 127.0.0.1
])("canonicalizes the loopback spelling %j and returns the trio", (host) => {
expect(defaultAllowedOrigins(host, 6274)).toEqual([
"http://localhost:6274",
"http://127.0.0.1:6274",
"http://[::1]:6274",
]);
});
it("unmaps an IPv4-mapped non-loopback host to its dotted form", () => {
expect(defaultAllowedOrigins("::ffff:192.168.1.50", 6274)).toEqual([
"http://192.168.1.50:6274",
]);
});
it("keeps a distinct non-loopback address (127.0.0.2) as a single origin", () => {
// 127.0.0.2 is canonical and a bind there doesn't serve 127.0.0.1, so the
// single-origin branch is correct — only non-canonical *spellings* expand.
expect(defaultAllowedOrigins("127.0.0.2", 6274)).toEqual([
"http://127.0.0.2:6274",
]);
});
it("handles an empty host (canonicalUrlHost's non-URL fallback) as the wildcard", () => {
// `new URL("http://")` throws, so canonicalUrlHost falls back to "" — which
// isAllInterfacesHost treats as the wildcard. No `http://:PORT` garbage.
expect(defaultAllowedOrigins("", 8123)).toEqual([
"http://localhost:8123",
"http://127.0.0.1:8123",
"http://[::1]:8123",
"http://0.0.0.0:8123",
"http://[::]:8123",
]);
});
// Any wildcard spelling yields the loopback trio + the CANONICAL wildcard pair
// (0.0.0.0 / [::]) — not the typed spelling, which the browser canonicalizes
// away (HOST=0 / 0x0 / 0.0.0 all send http://0.0.0.0:PORT; ::0 sends [::]).
it.each(["0.0.0.0", "::", "::0", "0", "0x0", "0.0.0"])(
"returns the loopback trio + canonical wildcard pair for the all-interfaces host %j",
(host) => {
expect(defaultAllowedOrigins(host, 8123)).toEqual([
"http://localhost:8123",
"http://127.0.0.1:8123",
"http://[::1]:8123",
"http://0.0.0.0:8123",
"http://[::]:8123",
]);
},
);
it("lowercases a non-loopback hostname to match the browser's Origin", () => {
expect(defaultAllowedOrigins("Example.COM", 6274)).toEqual([
"http://example.com:6274",
]);
});
it("omits the port from the origin when it's the http default (80)", () => {
// Browsers drop :80 from the Origin header, and the guard is an exact
// match, so an origin with :80 could never match a real request.
expect(defaultAllowedOrigins("192.168.1.50", 80)).toEqual([
"http://192.168.1.50",
]);
expect(defaultAllowedOrigins("localhost", 80)).toEqual([
"http://localhost",
"http://127.0.0.1",
"http://[::1]",
]);
});
it("brackets a non-loopback IPv6 literal so it's a valid origin", () => {
expect(defaultAllowedOrigins("fe80::1", 6274)).toEqual([
"http://[fe80::1]:6274",
]);
});
});
describe("buildWebServerConfig", () => {
it("matches buildWebServerConfigFromEnv when initialMcpConfig is omitted", () => {
process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "shared";
expect(buildWebServerConfig()).toEqual(buildWebServerConfigFromEnv());
expect(buildWebServerConfig({ initialMcpConfig: null })).toEqual(
buildWebServerConfigFromEnv(),
);
});
it("threads mcpConfigPath through and defaults it to undefined", () => {
expect(buildWebServerConfig().mcpConfigPath).toBeUndefined();
expect(
buildWebServerConfig({ mcpConfigPath: "/tmp/catalog/mcp.json" })
.mcpConfigPath,
).toBe("/tmp/catalog/mcp.json");
});
it("defaults writable to true and initialServers to null", () => {
const cfg = buildWebServerConfig();
expect(cfg.writable).toBe(true);
expect(cfg.initialServers).toBeNull();
});
it("threads writable and initialServers when provided", () => {
const initialServers = {
mcpServers: { srv: { type: "stdio" as const, command: "node" } },
};
const cfg = buildWebServerConfig({ writable: false, initialServers });
expect(cfg.writable).toBe(false);
expect(cfg.initialServers).toBe(initialServers);
});
it("preserves a stdio initialMcpConfig while applying shared env defaults", () => {
process.env.CLIENT_PORT = "7000";
const initialMcpConfig = {
type: "stdio" as const,
command: "node",
args: ["server.js"],
cwd: "/srv",
};
const cfg = buildWebServerConfig({ initialMcpConfig });
expect(cfg.port).toBe(7000);
expect(cfg.initialMcpConfig).toEqual(initialMcpConfig);
expect(cfg.allowedOrigins).toEqual([
"http://localhost:7000",
"http://127.0.0.1:7000",
"http://[::1]:7000",
]);
});
it("preserves remote transport initialMcpConfig", () => {
const initialMcpConfig = {
type: "streamable-http" as const,
url: "https://example.com/mcp",
};
const cfg = buildWebServerConfig({ initialMcpConfig });
expect(cfg.initialMcpConfig).toEqual(initialMcpConfig);
});
});
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("tags the payload with the single-source Inspector version", () => {
// Read from the root package.json via readInspectorVersion() at module load.
const payload = webServerConfigToInitialPayload(baseConfig());
expect(payload.version).toMatch(/^\d+\.\d+\.\d+/);
});
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",
};
const payload = webServerConfigToInitialPayload(cfg);
expect(payload.defaultTransport).toBe("sse");
expect(payload.defaultServerUrl).toBe("https://srv/sse");
});
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");
});
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",
} as unknown as WebServerConfig["initialMcpConfig"];
const payload = webServerConfigToInitialPayload(cfg);
expect(payload.defaultTransport).toBe("streamable-http");
expect(payload.defaultServerUrl).toBe("https://srv/other");
});
});
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://127.0.0.1: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://127.0.0.1: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://127.0.0.1:6274");
});
it("brackets an IPv6 bind host in the printed URL", () => {
const cfg = baseConfig();
cfg.hostname = "::1";
const url = printServerBanner(cfg, 6274, "", undefined);
expect(url).toBe("http://[::1]:6274");
});
it("advertises localhost for a wildcard bind rather than the wildcard host", () => {
const cfg = baseConfig();
cfg.hostname = "0.0.0.0";
const url = printServerBanner(cfg, 6274, "", undefined);
expect(url).toBe("http://localhost:6274");
});
it("advertises the canonical (unmapped) host so banner ⊆ allowedOrigins", () => {
// An IPv4-mapped bind host: the allow-list emits the loopback trio (which
// includes http://127.0.0.1), so the banner must advertise a member of it,
// not the [::ffff:127.0.0.1] form (whose Origin is [::ffff:7f00:1]).
const cfg = baseConfig();
cfg.hostname = "::ffff:127.0.0.1";
const url = printServerBanner(cfg, 6274, "", undefined);
expect(url).toBe("http://127.0.0.1:6274");
expect(defaultAllowedOrigins(cfg.hostname, 6274)).toContain(url);
});
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;
},
};
}