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

@ -26,21 +26,46 @@ function isEnabled(value: string | undefined): boolean {
return v === "true" || v === "1";
}
/**
* The default bind host: the IPv4 loopback **address**, deliberately not the
* name `localhost`.
*
* `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 Node takes whatever the resolver returns first rather than
* preferring IPv4 — and on a glibc Linux host with the stock `/etc/hosts`
* (`127.0.0.1 localhost` *and* `::1 localhost`) that is `::1`. `HOST=localhost`
* therefore binds IPv6 loopback **only**, and every IPv4 client is refused.
*
* That is invisible on a desktop, where the browser resolves `localhost` the
* same way and lands on `::1` too. It breaks wherever the connection is made by
* something that pins IPv4 `127.0.0.1` instead — a VS Code dev container's port
* forwarder (#1951), an `ssh -L …:127.0.0.1:…` tunnel, a container healthcheck.
* Binding the address directly removes the resolver from the decision, and the
* loopback origin allow-list already covers `localhost`, `127.0.0.1`, and
* `[::1]` alike, so browsing to `http://localhost:PORT` still works (browsers
* fall back across address families; a Node `listen` does not).
*/
export const DEFAULT_BIND_HOST = "127.0.0.1";
/**
* Resolve the bind hostname from `env` (default `process.env`), defaulting to
* `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their
* legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly
* enabled — the published Docker image sets it, since a container must bind
* `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than
* silently binding wide open. The returned value is trimmed and de-bracketed
* (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection,
* `listen()`, and the origin list all consume the same value; `formatHostForUrl`
* re-adds the brackets wherever a URL is built.
* {@link DEFAULT_BIND_HOST}. Refuses an all-interfaces host (`0.0.0.0` / `::` /
* empty / their legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is
* explicitly enabled — the published Docker image sets it, since a container
* must bind `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly)
* rather than silently binding wide open. The returned value is trimmed and
* de-bracketed (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so
* detection, `listen()`, and the origin list all consume the same value;
* `formatHostForUrl` re-adds the brackets wherever a URL is built.
*
* An explicit `HOST=localhost` is honored as typed — the single-family bind
* described on {@link DEFAULT_BIND_HOST} is then the caller's choice, not ours.
*/
export function resolveBindHostname(
env: NodeJS.ProcessEnv = process.env,
): string {
const host = (env.HOST ?? "localhost").trim();
const host = (env.HOST ?? DEFAULT_BIND_HOST).trim();
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
// Show the resolved address when it differs from the typed spelling — the
// guard now catches forms the resolver folds to the wildcard (a fullwidth