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>
689 lines
24 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|