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
10 changed files with 246 additions and 52 deletions

View file

@ -17,7 +17,10 @@ v2/main/
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions),
│ │ │ # resolve-bind-host.ts (bind-host POLICY: refuses an
│ │ │ # resolve-bind-host.ts (bind-host POLICY: defaults to
│ │ │ # 127.0.0.1 — an ADDRESS, never the name `localhost`, which
│ │ │ # listen() resolves to a SINGLE family (::1 on glibc Linux),
│ │ │ # refusing every IPv4 client — #1951; and refuses an
│ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES;
│ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost.
│ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795),

View file

@ -31,8 +31,12 @@ RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz
ENV HOST=0.0.0.0 \
DANGEROUSLY_BIND_ALL_INTERFACES=true \
CLIENT_PORT=6274 \
MCP_SANDBOX_PORT=6275 \
MCP_AUTO_OPEN_ENABLED=false
EXPOSE 6274
# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly.
# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is
# optional — see the Docker section of the root README.
EXPOSE 6274 6275
# Run as the non-root `node` user the base image ships. The inspector resolves
# its runtime-state dir (default catalog, OAuth token storage) from `HOME`

View file

@ -349,6 +349,15 @@ docker build -t mcp-inspector .
docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector
```
**Using the Apps tab? Publish `6275` too.** The MCP Apps sandbox is a second listener the browser reaches directly, on `MCP_SANDBOX_PORT` (default `6275`). Nothing else needs it, so the single-port commands above are fine for ordinary inspection — but the Apps tab renders a blank widget without it:
```bash
docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \
ghcr.io/modelcontextprotocol/inspector
```
Publish it on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead.
**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one.
**Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it:

View file

@ -162,7 +162,9 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer
## Host binding & the origin allow-list
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds **`127.0.0.1`** by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
**The default is the IPv4 loopback _address_, deliberately not the name `localhost`** (#1951). `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 first address is `::1`. `HOST=localhost` there binds IPv6 loopback **only** and refuses every IPv4 client. On a desktop this is invisible (the browser resolves `localhost` the same way and lands on `::1` too); it breaks wherever the connection is made by something that pins `127.0.0.1` — a VS Code dev container's port forwarder, `ssh -L 6274:127.0.0.1:6274`, a container healthcheck. Binding the address takes the resolver out of the decision. Browsing to `http://localhost:PORT` still works, since browsers fall back across address families and the loopback allow-list covers all three forms. An explicit `HOST=localhost` is still honored as typed — the single-family bind is then your choice.
The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off.
@ -176,7 +178,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe
The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`.
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard.

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

View file

@ -11,13 +11,14 @@ import {
canonicalUrlHost,
isAllInterfacesHost,
} from "../../../core/node/hostUrl.ts";
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export interface SandboxControllerOptions {
/** Port to bind (0 = dynamic). */
port: number;
/** Host to bind (default localhost). */
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
host?: string;
/**
* The backend's origin allow-list (the embedder origins). Used to build the
@ -95,10 +96,28 @@ function parseListenPort(raw: string | undefined): number | undefined {
}
/**
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
* An invalid value falls through rather than crashing the boot; a set-but-invalid
* MCP_SANDBOX_PORT (the dedicated knob) is warned so the fall-through isn't
* silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS.
* The default sandbox listen port — **fixed**, deliberately not `0`.
*
* A `0` default lets the OS assign, which is fine on a desktop (the browser
* learns the port from `/api/config`) but unusable anywhere the port has to be
* declared *before* the process starts: a dev container's `forwardPorts`, a
* `docker run -p`, a k8s service, an SSH tunnel. The port changed on every run,
* so it could never be named in that config and the Apps tab was unreachable
* (#2008). `CLIENT_PORT` has always defaulted to a fixed `6274` for the same
* reason; this is its sandbox counterpart.
*
* `6275` sits next to the web port so the pair is easy to forward together. It
* is not a v1 carry-over — v2 has no proxy server on it.
*/
export const DEFAULT_SANDBOX_PORT = 6275;
/**
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT →
* {@link DEFAULT_SANDBOX_PORT}. An invalid value falls through rather than
* crashing the boot; a set-but-invalid MCP_SANDBOX_PORT (the dedicated knob) is
* warned so the fall-through isn't silent — matching the warn-and-drop
* precedent for ALLOWED_ORIGINS. An explicit `0` is still honored as "let the
* OS assign" ({@link parseListenPort} accepts it).
*/
export function resolveSandboxPort(): number {
const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT);
@ -107,13 +126,21 @@ export function resolveSandboxPort(): number {
`Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`,
);
}
return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0;
return (
fromSandbox ??
parseListenPort(process.env.SERVER_PORT) ??
DEFAULT_SANDBOX_PORT
);
}
export function createSandboxController(
options: SandboxControllerOptions,
): SandboxController {
const { port, host = "localhost", allowedOrigins } = options;
// Defaulted to the same address the web server binds, never the name
// `localhost` — a name resolves to one address family and would reintroduce
// the #1951 split (web on IPv4, sandbox on IPv6) for any future call site
// that omits `host`. Both call sites pass `config.sandboxHost` today.
const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options;
let server: Server | null = null;
let sandboxUrl: string | null = null;
@ -177,8 +204,28 @@ export function createSandboxController(
});
res.end(sandboxHtml);
});
// A taken port used to mean "no sandbox at all", which was tolerable
// only because the default was dynamic and therefore never collided.
// Now that it's fixed (see DEFAULT_SANDBOX_PORT), a second Inspector —
// or anything else already on 6275 — would take the Apps tab down, so
// retry once on an OS-assigned port. The retry is announced loudly:
// whoever pinned MCP_SANDBOX_PORT to forward it needs to know the pin
// didn't take, and a silently-moved port is exactly the failure this
// default was introduced to fix.
let retriedDynamic = false;
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
if (!retriedDynamic && port !== 0) {
retriedDynamic = true;
console.warn(
`Sandbox: port ${port} in use; falling back to an OS-assigned port. ` +
`The MCP Apps tab will work locally, but the sandbox is no longer ` +
`on a predictable port — set MCP_SANDBOX_PORT to a free one if you ` +
`need to forward it (container / SSH tunnel / reverse proxy).`,
);
server!.listen(0, host);
return;
}
console.error(
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`,
);

View file

@ -1,12 +1,24 @@
import { describe, it, expect } from "vitest";
import {
BIND_ALL_INTERFACES_ENV,
DEFAULT_BIND_HOST,
resolveBindHostname,
} from "../../../../server/resolve-bind-host.js";
describe("resolveBindHostname", () => {
it("defaults to localhost when HOST is unset", () => {
expect(resolveBindHostname({})).toBe("localhost");
// Regression guard for #1951. The default must be an *address*, never the
// name `localhost`: `listen()` resolves a name and binds the single address
// the resolver returns first, which on glibc Linux is `::1` — leaving every
// IPv4 client (a dev container's port forwarder, an `ssh -L` to 127.0.0.1, a
// container healthcheck) refused. See DEFAULT_BIND_HOST.
it("defaults to the IPv4 loopback address, not the name localhost", () => {
expect(resolveBindHostname({})).toBe("127.0.0.1");
expect(resolveBindHostname({})).toBe(DEFAULT_BIND_HOST);
expect(resolveBindHostname({})).not.toBe("localhost");
});
it("still honors an explicit HOST=localhost as typed", () => {
expect(resolveBindHostname({ HOST: "localhost" })).toBe("localhost");
});
it("returns a loopback HOST unchanged", () => {

View file

@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createServer, type Server } from "node:http";
import {
createSandboxController,
DEFAULT_SANDBOX_PORT,
resolveSandboxPort,
sandboxFrameAncestors,
} from "../../../../server/sandbox-controller.js";
@ -75,7 +76,17 @@ describe("resolveSandboxPort", () => {
else process.env.SERVER_PORT = envSnapshot.server;
});
it("returns 0 when no env vars are set", () => {
// Regression guard for #2008: the default must be a FIXED port. An
// OS-assigned one changes every run, so it can never be named in a dev
// container's `forwardPorts`, a `docker run -p`, or an SSH tunnel — leaving
// the Apps tab unreachable in every such environment.
it("returns a fixed default port when no env vars are set", () => {
expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT);
expect(resolveSandboxPort()).not.toBe(0);
});
it("still honors an explicit MCP_SANDBOX_PORT=0 as OS-assigned", () => {
process.env.MCP_SANDBOX_PORT = "0";
expect(resolveSandboxPort()).toBe(0);
});
@ -102,19 +113,19 @@ describe("resolveSandboxPort", () => {
expect(resolveSandboxPort()).toBe(9100);
});
it("returns 0 when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => {
it("falls back to the default when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => {
process.env.SERVER_PORT = "not-a-port";
expect(resolveSandboxPort()).toBe(0);
expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT);
});
it("ignores empty-string SERVER_PORT", () => {
process.env.SERVER_PORT = "";
expect(resolveSandboxPort()).toBe(0);
expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT);
});
it("ignores negative values", () => {
process.env.MCP_SANDBOX_PORT = "-1";
expect(resolveSandboxPort()).toBe(0);
expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT);
});
it("rejects a partial-parse value (6274abc) rather than binding 6274", () => {
@ -125,7 +136,7 @@ describe("resolveSandboxPort", () => {
it("rejects an out-of-range value (70000) so it can't crash listen", () => {
process.env.MCP_SANDBOX_PORT = "70000";
expect(resolveSandboxPort()).toBe(0);
expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT);
});
});
@ -135,7 +146,8 @@ describe("createSandboxController", () => {
try {
const { url, port } = await controller.start();
expect(port).toBeGreaterThan(0);
expect(url).toBe(`http://localhost:${port}/sandbox`);
// Default host is now the IPv4 loopback address, not the name (#1951).
expect(url).toBe(`http://127.0.0.1:${port}/sandbox`);
expect(controller.getUrl()).toBe(url);
const res = await fetch(url);
@ -312,11 +324,11 @@ describe("createSandboxController", () => {
}
});
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.
/** Claim a port so a subsequent listen on it fails with EADDRINUSE. */
async function claimPort(): Promise<{
port: number;
release: () => Promise<void>;
}> {
const blocker: Server = createServer();
await new Promise<void>((resolve) =>
blocker.listen(0, "127.0.0.1", () => resolve()),
@ -327,21 +339,96 @@ describe("createSandboxController", () => {
? addr.port
: 0;
expect(port).toBeGreaterThan(0);
return {
port,
release: () =>
new Promise<void>((resolve) => blocker.close(() => resolve())),
};
}
it("falls back to an OS-assigned port when the fixed port is taken", async () => {
// #2008 made the default port fixed, which introduced a collision the
// dynamic default never had: a second Inspector, or anything else already
// on 6275, would have taken the Apps tab down entirely. Retrying on a
// dynamic port keeps it working locally.
const { port, release } = await claimPort();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const controller = createSandboxController({ port, host: "127.0.0.1" });
try {
const result = await controller.start();
expect(result.port).toBeGreaterThan(0);
expect(result.port).not.toBe(port);
expect(result.url).toBe(`http://127.0.0.1:${result.port}/sandbox`);
expect(controller.getUrl()).toBe(result.url);
// The sandbox must actually be serving on the fallback port, not merely
// reporting one.
expect((await fetch(result.url)).status).toBe(200);
// Loud, not silent: a pinned-and-forwarded port that quietly moved is the
// very failure the fixed default exists to prevent.
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(`Sandbox: port ${port} in use`),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP_SANDBOX_PORT"),
);
} finally {
warnSpy.mockRestore();
await controller.close();
await release();
}
});
it("resolves with empty values when even the dynamic retry fails", async () => {
// The Vite plugin awaits start() in configureServer; if start() ever stops
// resolving, the entire dev backend hangs. This pins the resolve-on-error
// contract for the case the retry can't rescue — an explicit `port: 0` that
// fails has nowhere left to fall back to, so it degrades as before.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const controller = createSandboxController({ port: 0, host: "127.0.0.1" });
try {
// Force the listen to fail the way a dynamic listen can (an unusable bind
// address), since port 0 cannot collide.
const broken = createSandboxController({
port: 0,
host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface
});
const result = await broken.start();
expect(result).toEqual({ port: 0, url: "" });
expect(broken.getUrl()).toBeNull();
// close() must be a no-op since the server never bound.
await expect(broken.close()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
"Sandbox server error:",
expect.objectContaining({ code: "EADDRNOTAVAIL" }),
);
} finally {
errorSpy.mockRestore();
await controller.close();
}
});
it("gives up after one retry when the dynamic fallback also fails", async () => {
// Guards the `retriedDynamic` latch: without it an EADDRINUSE loop could
// re-listen forever instead of settling.
const { port, release } = await claimPort();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
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`),
);
// The retry succeeds here (a dynamic port is always available), so assert
// the latch is what bounds it: exactly one retry warning was emitted.
expect(
warnSpy.mock.calls.filter((c) =>
String(c[0]).includes("falling back to an OS-assigned port"),
),
).toHaveLength(1);
expect(result.port).toBeGreaterThan(0);
} finally {
warnSpy.mockRestore();
errorSpy.mockRestore();
await new Promise<void>((resolve) => blocker.close(() => resolve()));
await controller.close();
await release();
}
});

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", () => {

View file

@ -66,7 +66,7 @@ Consequences:
- `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy.
- Docker needs `-p 6274:6274` for the UI, where the v1 recipe published `6277` as well.
⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a dynamic port by default. It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a remote host, an SSH tunnel) you must pin it with `MCP_SANDBOX_PORT` and expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network).
⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a fixed **`6275`** by default (it was OS-assigned before #2008). It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a dev container, a remote host, an SSH tunnel) you must expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network).
## Launching
@ -280,7 +280,7 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th
| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI |
| — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) |
| — | `MCP_CATALOG_PATH` | Default catalog path |
| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) |
| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (fixed `6275` by default) |
| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations |
| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback |
@ -355,7 +355,9 @@ Notes:
**"`Transport type not specified and could not be determined from URL`."** The path ends in neither `/mcp` nor `/sse`, so v2 refuses to guess where v1 fell back to SSE. Pass `--transport http` or `--transport sse`.
**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second, dynamic port. Pin it with `MCP_SANDBOX_PORT` and publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network).
**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network).
**"The page never loads inside a VS Code dev container (or any container)."** Fixed in the release carrying #1951 — upgrade. Before that fix the default `HOST=localhost` bound IPv6 loopback only on Linux, so the container's port forwarder (which connects over IPv4 `127.0.0.1`) could never reach it; the terminal printed a normal banner while the browser spun forever. On an older version, work around it with `HOST=127.0.0.1`. Note the failure is a **hang**, not a connection error, because the forwarder accepts on the host side before failing to connect inward.
**"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list).