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>
This commit is contained in:
cliffhall 2026-08-15 12:13:06 -04:00
commit adde9ccf94

View file

@ -7,6 +7,7 @@ import {
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,
@ -32,7 +33,7 @@ const MUTATED_ENV_KEYS = [
const baseConfig = (): WebServerConfig => ({
port: 6274,
hostname: "localhost",
hostname: "127.0.0.1",
authToken: "tok",
dangerouslyOmitAuth: false,
initialMcpConfig: null,
@ -42,7 +43,7 @@ const baseConfig = (): WebServerConfig => ({
storageDir: undefined,
allowedOrigins: ["http://localhost:6274"],
sandboxPort: 0,
sandboxHost: "localhost",
sandboxHost: "127.0.0.1",
logger: undefined,
autoOpen: false,
});
@ -72,7 +73,7 @@ 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.hostname).toBe("127.0.0.1");
expect(cfg.authToken).toBe("");
expect(cfg.dangerouslyOmitAuth).toBe(false);
expect(cfg.initialMcpConfig).toBeNull();
@ -82,8 +83,8 @@ describe("buildWebServerConfigFromEnv", () => {
"http://127.0.0.1:6274",
"http://[::1]:6274",
]);
expect(cfg.sandboxPort).toBe(0);
expect(cfg.sandboxHost).toBe("localhost");
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
@ -289,7 +290,9 @@ describe("buildWebServerConfigFromEnv", () => {
it("ignores non-numeric SERVER_PORT", () => {
process.env.SERVER_PORT = "nope";
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(0);
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(
DEFAULT_SANDBOX_PORT,
);
});
it("treats MCP_SANDBOX_PORT empty string as unset", () => {
@ -596,7 +599,7 @@ describe("printServerBanner", () => {
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`,
`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,
@ -607,7 +610,7 @@ describe("printServerBanner", () => {
const cfg = baseConfig();
cfg.dangerouslyOmitAuth = true;
const url = printServerBanner(cfg, 6274, "irrelevant", undefined);
expect(url).toBe("http://localhost:6274");
expect(url).toBe("http://127.0.0.1:6274");
expect(
logSpy.lines.some((l) =>
l.includes("Auth: disabled (DANGEROUSLY_OMIT_AUTH)"),
@ -617,7 +620,7 @@ describe("printServerBanner", () => {
it("omits the query string when no token is supplied", () => {
const url = printServerBanner(baseConfig(), 6274, "", undefined);
expect(url).toBe("http://localhost:6274");
expect(url).toBe("http://127.0.0.1:6274");
});
it("brackets an IPv6 bind host in the printed URL", () => {