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:
parent
5ec2ecf956
commit
adde9ccf94
10 changed files with 246 additions and 52 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue