Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cliffhall marked this conversation as resolved.
│ │ │ # 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),
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions clients/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
43 changes: 34 additions & 9 deletions clients/web/server/resolve-bind-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 54 additions & 7 deletions clients/web/server/sandbox-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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;

Expand Down Expand Up @@ -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.`,
);
Expand Down
Loading
Loading