# Migrating from Inspector v1 to v2 v2 is a rewrite. The package name and the `npx @modelcontextprotocol/inspector` entry point are unchanged, but the architecture, the CLI surface, and the way you point the Inspector at a server all changed. This guide is the map. It covers what breaks, what it becomes, and the handful of places where a v1 command still runs in v2 but means something different — the ones worth reading before you conclude v2 is broken. > **v1 is deprecated** and receives security fixes only, published to the `v1-latest` npm dist-tag. To stay on it, pin it explicitly: > > ```bash > npx @modelcontextprotocol/inspector@v1-latest > ``` ## At a glance | | v1 | v2 | | ----------------- | ------------------------------------------------ | --------------------------------------------------------------- | | Node | `>=22.7.5` | **`>=22.19.0`** | | npm packages | 4 (`inspector` + `-client` / `-server` / `-cli`) | **1** (`@modelcontextprotocol/inspector`) | | Processes / ports | web UI on `6274` **+ MCP proxy on `6277`** | one web server on `6274` (plus a dynamic MCP Apps sandbox port) | | Modes | web, `--cli` | web, `--cli`, **`--tui`** | | Server list | per-browser, in `localStorage` | a **catalog file** on disk (`~/.mcp-inspector/mcp.json`) | | Auth token env | `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | | CLI exit codes | `0` / `1` | `0`–`5`, plus a JSON error envelope on stderr | The three changes most likely to bite an existing setup: 1. **`--config` no longer means what it did** — see [`--config` vs `--catalog`](#--config-vs---catalog). 2. **A failing tool call now exits non-zero** (`5`), where v1 exited `0`. CI scripts that ran `&&` chains past a failed call will start failing — correctly. 3. **`SERVER_PORT` was the proxy port and now isn't** — it survives as a fallback for the MCP Apps sandbox port. Setting it no longer moves anything you can browse. ## Requirements v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. npm only _warns_ about an `engines` mismatch (`EBADENGINE`) unless you have `engine-strict=true` set, so an older Node won't stop you at install time — it fails later, obscurely. Check `node -v` first. ## What no longer ships v1 published four packages: - `@modelcontextprotocol/inspector` - `@modelcontextprotocol/inspector-client` - `@modelcontextprotocol/inspector-server` - `@modelcontextprotocol/inspector-cli` **v2 publishes only the first.** It is a single tarball with a single version number, containing the web client, the CLI, the TUI, and the launcher that dispatches between them. The three sub-packages are frozen at `1.0.1`, deprecated on npm, and will never see a 2.x. If you depend on one of them directly, drop it and use the root package's `mcp-inspector` bin. There is no v2 equivalent of importing `inspector-server` as a library; the shared runtime lives in this repo's `core/` and is not published separately yet ([#1636](https://github.com/modelcontextprotocol/inspector/issues/1636)). ```diff { "devDependencies": { - "@modelcontextprotocol/inspector-cli": "^1.0.1" + "@modelcontextprotocol/inspector": "^2.0.0" } } ``` ## Architecture: the proxy is gone v1 ran **two** processes: a React client on `6274` and an "MCP Proxy" on `6277` that held the actual MCP connections. The browser talked to the proxy over HTTP, authenticating with `MCP_PROXY_AUTH_TOKEN`. v2 runs **one** web server on `6274`. It serves the SPA and exposes `/api/*`, which the browser calls with `x-mcp-remote-auth: Bearer `. The proxy port is gone: nothing needs `6277` exposed, forwarded, or allowed through a firewall. Consequences: - `SERVER_PORT` no longer selects a port you browse. (It is read as a fallback for the MCP Apps sandbox server's port — see `MCP_SANDBOX_PORT` below.) - `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 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 ```bash npx @modelcontextprotocol/inspector # web UI (unchanged default) npx @modelcontextprotocol/inspector --cli # CLI npx @modelcontextprotocol/inspector --tui # TUI (new in v2) ``` The mode flag must come **first**, immediately after the binary name; everything after it is forwarded to that client unchanged. Passing a server ad-hoc works the same way it did: ```bash npx @modelcontextprotocol/inspector node build/index.js npx @modelcontextprotocol/inspector -e KEY=value -- node build/index.js --server-flag ``` ## `--config` vs `--catalog` **This is the change most likely to surprise you.** Both versions have a `--config` flag, both take an `mcpServers` file, and they do not mean the same thing. In v1, `--config path.json --server name` resolved one entry out of the file and launched against it. The file was read once and never written — but there was no other file, because v1's web UI kept its server list in browser `localStorage`. v2 has a first-class **server catalog** on disk, and splits the two roles: | | `--catalog ` | `--config ` | | ------------------------- | -------------------------------------------------- | -------------------------------------------------------- | | Writable by the Inspector | **Yes** — this is the Inspector's own list | **No.** Served as-is; never written, seeded, or migrated | | When the file is missing | created and seeded | **errors** | | Default path | `~/.mcp-inspector/mcp.json`, or `MCP_CATALOG_PATH` | none — must be passed | | Editable in the web UI | yes | no (catalog CRUD is hidden) | So: - **`--config` in v2 is the read-only role.** Point it at a file you didn't write — a coworker's, a client application's, one checked into a repo — and the Inspector guarantees it won't touch the bytes, including any plaintext secrets in them. This is close to v1's behavior, minus the write-back that never existed anyway. - **`--catalog` is new** and is what you want if you'd like the web UI to add, edit, and remove servers. - **Neither is required.** With no source flag, v2 uses the default catalog `~/.mcp-inspector/mcp.json`, creating it if absent. v1 had no default file: the CLI started with nothing unless you passed `--config`, and the web UI's list lived in the browser's `localStorage` — per-browser, and invisible to the CLI. ### Before / after **Your v1 file works unchanged.** The format is the same `mcpServers` shape, `type: "streamable-http"` is still accepted (as is the `"http"` alias), and unknown fields such as v1's exported `note` are carried through rather than rejected. ```bash # v1 — one entry out of a file npx @modelcontextprotocol/inspector --config ./mcp.json --server everything # v2 — same file, same read-only guarantee; --server selects under --cli npx @modelcontextprotocol/inspector --cli --config ./mcp.json --server everything --method tools/list ``` ⚠️ **`--server` only selects under `--cli`**, which is why the v2 line above gains the mode flag (and, being a CLI invocation, a `--method`). On the web client `--server` is a no-op that logs a warning and the UI loads **every** entry in the file; the TUI rejects it as an unknown option. There is no web equivalent of "open just this one entry" — you pick the server from the list after it loads. ```bash # v2 — let the Inspector manage the file (web UI can edit it) npx @modelcontextprotocol/inspector --catalog ./mcp.json # v2 — the default catalog, no flags at all npx @modelcontextprotocol/inspector ``` **Rules:** `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target (a positional command, `--server-url`, or `--transport`). v1 silently preferred the config file; v2 tells you. One exception: the **web** client exempts `--transport stdio` from that check, so it survives alongside `--catalog`/`--config` (ignored rather than rejected) — the CLI and TUI reject every `--transport`. If you export `MCP_CATALOG_PATH` in your shell, note that **web and TUI read it unconditionally** — so an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as a catalog/ad-hoc conflict. Unset it for that invocation. The CLI ignores the variable whenever an ad-hoc target is present. The full model, including the Inspector-specific per-server fields v2 adds (`protocolEra`, `roots`, `requestTimeout`, `oauth`, …), is documented in [MCP server configuration](./mcp-server-configuration.md). ## CLI flag mapping Every v1 CLI flag still exists in v2 and means the same thing. Nothing was renamed or removed: | v1 flag | v2 | Notes | | -------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- | | `--method ` | same | v2 adds `initialize`, `servers/list`, `servers/show`; stream-only methods are rejected explicitly | | `--tool-name ` | same | | | `--tool-arg ` | same | | | `--uri ` | same | | | `--prompt-name ` | same | | | `--prompt-args ` | same | | | `--log-level ` | same | | | `--transport ` | same | | | `--server-url ` | same | | | `--header "Name: Value"` | same | | | `--metadata ` | same | | | `--tool-metadata ` | same | | | `-e KEY=VALUE` | same | | | `--config ` | same flag, **narrower meaning** | see [above](#--config-vs---catalog) | | `--server ` | same | now `--cli`-only | | `[target...]` | same | but see the target-ordering **and** URL-transport rules below | New in v2, with no v1 equivalent: | Flag | What it does | | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `--catalog ` | writable catalog file (see above) | | `--cwd ` | working directory for a stdio server process | | `--tool-args-json ''` | tool arguments as one JSON object, passed verbatim (no `key=value` coercion, so `"012"` stays a string) | | `--format text\|json` | `json` emits a single `{"result":…}` object on stdout with no banners | | `--app-info` | probe a tool's MCP App UI metadata without invoking the tool | | `--connect-timeout ` | connect timeout; defaults to `15000` for ad-hoc targets so a black-holed host fails fast | | `--client-id` / `--client-secret` / `--client-metadata-url` / `--client-config` | OAuth client configuration | | `--callback-url ` | OAuth loopback redirect URI (default `http://127.0.0.1:6276/oauth/callback`) | | `--relogin` / `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / `--list-stored-auth` / `--print-handoff` | OAuth token reuse and web→CLI handoff | v1 had no OAuth support in the CLI at all; a protected server simply failed. If you script against protected servers, `--stored-auth-only` is the flag to reach for in CI — it never opens a browser and fails fast with `auth_required` instead of hanging on a loopback callback. See the [CLI README](../clients/cli/README.md) for the full surface. ## CLI behavior changes The flags survived; four behaviors did not. ### 1. Exit codes and the error envelope v1 exited `0` on success and `1` on any failure — including a `tools/call` that came back `isError: true`, which exited `0` because the _call_ succeeded. v2 maps failures onto a stable set: | Code | Meaning | | ---- | ------------------------------------------------------- | | `0` | Success | | `1` | Usage / unexpected error | | `2` | No MCP App on the tool (`--app-info` probe) | | `3` | Server requires authentication | | `4` | Server unreachable (DNS, refused, timeout) | | `5` | Tool error — `isError: true`, or the tool was not found | and writes one JSON line to **stderr** on any non-zero exit: ```json { "error": { "code": "auth_required", "message": "Unauthorized", "status": 401, "url": "https://api.example/mcp" } } ``` **Migration note:** a CI step that chained `inspector --cli … --method tools/call … && next-step` silently continued past failing tool calls in v1. In v2 it stops. That is the intended behavior, but it will surface as "v2 broke my pipeline" the first time a tool was failing all along. Parse the class with `2>&1 | tail -1 | jq .error` rather than matching on prose. ### 2. The target must come first v2's CLI reads the leading run of non-dash tokens as the server target. Anything after the first flag is no longer part of it: ```bash mcp-inspector --cli node build/index.js --method tools/list # ✅ mcp-inspector --cli --method tools/list node build/index.js # ❌ target silently dropped ``` The second form does **not** error — the target is discarded and the Inspector falls back to your catalog, so it appears to work against the wrong server. v1 tolerated either order. ### 3. `--` splits the other way under `--cli` On **web and TUI**, everything _after_ `--` goes to the target command — same as v1: ```bash mcp-inspector node build/index.js -- --config /etc/myserver.conf --verbose ``` Under **`--cli`** it is reversed: everything _before_ `--` is the target, everything _after_ is the Inspector's own options. ```bash mcp-inspector --cli node build/index.js -- --method tools/list ``` So the web example above, run verbatim under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). To pass a leading-dash argument through to a stdio server, put it **before** the `--` instead — everything on that side is forwarded to the target untouched, flags included: ```bash # v1 mcp-inspector node build/index.js -- --config /etc/myserver.conf --verbose # v2, same thing under --cli — target and its flags first, Inspector options after mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --method tools/list ``` (Without a `--` on the line, the target is only the leading run of non-dash tokens — so `--` is required whenever the server itself takes flags.) ### 4. An ambiguous URL path no longer guesses a transport With no `--transport`, v2 infers it from the URL's path suffix — and **only** from that: | URL path | v2 | | -------------- | ---------------------------------- | | ends in `/mcp` | `streamable-http` | | ends in `/sse` | `sse` | | anything else | **error — `--transport` required** | ``` Transport type not specified and could not be determined from URL: . ``` v1 fell back to SSE for an unrecognized path, so a server at e.g. `https://example.com/api` connected without a flag. In v2 the same command stops with the error above; pass `--transport http` (or `sse`) explicitly. The suffix match is exact, so a trailing slash (`…/mcp/`) is ambiguous too. This applies to every client's command line — CLI, TUI, and `--web` (which prints the message and exits `1`). The **browser deep link** is the one exception: `?serverUrl=…` with no `transport` param defaults to `http`. Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`. One byte differs — v2 appends a trailing newline where v1 wrote none — so a script diffing raw stdout against a stored v1 fixture needs the fixture re-captured (or the comparison trimmed). ## Environment variables This table maps v1 names to v2. Defaults, accepted values, and the variables with no v1 counterpart are in [Environment variables](./environment-variables.md). | v1 | v2 | Notes | | ------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed, but the **old name still works** as a deprecated fallback when the new one is unset — an existing deployment keeps running while you migrate. Guards `/api/*` via `x-mcp-remote-auth: Bearer `; the browser also receives it injected into `index.html`, so a bare reload keeps working. (The `?MCP_PROXY_AUTH_TOKEN=` **query param** has no such fallback — see [Web UI](#web-ui).) | | `MCP_PROXY_FULL_ADDRESS` | — | Removed; there is no proxy | | `SERVER_PORT` | _repurposed_ | Was the proxy port. Now only a fallback for the MCP Apps sandbox port | | `CLIENT_PORT` | same | Web UI port, default `6274`. Must be a fixed port — `0`/dynamic is rejected, since the origin allow-list and sandbox CSP derive from it | | `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | | `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | | `DANGEROUSLY_OMIT_AUTH` | same | | | `MCP_AUTO_OPEN_ENABLED` | same | Also governs opening the OAuth page in the CLI (`true` opens it even when stderr is not a TTY). The TUI does not read it | | — | `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 (fixed `6275` by default) | | — | `MCP_APP_ORIGIN_PORT` | Dedicated app-origin server port (fixed `6278` by default), used only by an MCP App whose UI resource declares `_meta.ui.domain` | | — | `MCP_SANDBOX_FULL_ADDRESS` | Public MCP Apps sandbox URL advertised instead of the bind-derived one, for a reverse proxy (#1862). The v2 counterpart of v1's `MCP_PROXY_FULL_ADDRESS`, but refused when it shares the Inspector's origin | | — | `MCP_APP_ORIGIN_FULL_ADDRESS` | Public origin for `_meta.ui.domain` app documents, for a reverse proxy (#1862) | | — | `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 | ### The v1 UI configuration settings v1 exposed a **Configuration** panel writing `localStorage` keys, also settable by query param. Those were global; in v2 the equivalents are **per-server settings**, stored in the catalog entry and editable in Server Settings: | v1 setting | v2 | | --------------------------------------- | ----------------------------------- | | `MCP_SERVER_REQUEST_TIMEOUT` | per-server `requestTimeout` (ms) | | _(no v1 equivalent)_ | per-server `connectionTimeout` (ms) | | `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | always on; no longer configurable | | `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | no equivalent | | `MCP_PROXY_FULL_ADDRESS` | no equivalent (no proxy) | ## Web UI **Server list.** v1 kept it in browser `localStorage`, so it was per-browser and invisible to the CLI. v2 keeps it in the catalog file, shared by all three clients — add a server in the web UI and the CLI and TUI see it. **Query params.** v1 accepted `?transport=…&serverUrl=…&serverCommand=…&serverArgs=…` plus `?MCP_PROXY_AUTH_TOKEN=…`. v2's deep link is narrower and gated: ``` http://127.0.0.1:6274/?serverUrl=&transport=http|sse&autoConnect= ``` - `serverCommand` / `serverArgs` are gone — a URL can no longer ask the Inspector to spawn a process. - `serverUrl` is restricted to `http:` / `https:`. - `autoConnect` must equal the per-launch `MCP_INSPECTOR_API_TOKEN`, so a third-party-minted link can't drive a connect. - `?MCP_INSPECTOR_API_TOKEN=…` replaces `?MCP_PROXY_AUTH_TOKEN=…`. Three further params (`openApp`, `appArgs`, `autoOpen`) land you on a rendered MCP App; see the [web README](../clients/web/README.md#deep-link-auto-connect). **Auth.** v1's bearer-token field in the sidebar is replaced by per-server `headers` and real OAuth (including CIMD and enterprise-managed auth) configured in Server Settings and persisted in the catalog. ## Docker ```bash # v1 docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ -e HOST=0.0.0.0 -e MCP_AUTO_OPEN_ENABLED=false \ ghcr.io/modelcontextprotocol/inspector:1.0.1 # v2 — no proxy port; the image already sets the wildcard-bind opt-in docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector:latest ``` Notes: - **Keep the `127.0.0.1:` prefix on the published port**, as the v1 recipe did. `-p 6274:6274` publishes on every host interface, and the container's `HOST=0.0.0.0` is about the _container's_ interfaces, not the host's — the two are independent. That matters here because the backend spawns processes, `/` embeds the API token, and requests arriving with **no** `Origin` header (i.e. anything that isn't a browser) skip the origin allow-list, leaving the token as the only guard. Bind to loopback and opt into wider exposure deliberately. - **The Apps tab needs one more published port.** The sandbox server listens on `6275` by default, so the recipe above covers everything _except_ MCP Apps. To use them, publish that port as well: ```bash docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \ ghcr.io/modelcontextprotocol/inspector:latest ``` Publish it on the **same port number** inside and out: the sandbox URL is handed to the browser via `/api/config` as `http://localhost:/sandbox`, so remapping it advertises a port the browser can't reach. To use a different number, move both ends — `-e MCP_SANDBOX_PORT=6280 -p 127.0.0.1:6280:6280`. - **And a third port for an App that declares `_meta.ui.domain`.** That spec field asks the host for a stable, dedicated origin, which v2 answers with a further listener on `MCP_APP_ORIGIN_PORT` (fixed `6278` by default) that the browser also reaches directly. Apps declaring no `domain` never touch it, so publish it only if you need one: ```bash docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6278:6278 \ ghcr.io/modelcontextprotocol/inspector:latest ``` Same-number rule and same remap escape hatch (`-e MCP_APP_ORIGIN_PORT=6281 -p 127.0.0.1:6281:6281`). Unlike the other degradations, leaving this **unpublished does not fall back**: the backend's listener binds fine inside the container, so publication succeeds and the browser is handed a URL on a port it cannot reach — that app's frame stays blank, with no console warning, because a cross-origin navigation failure is not observable from the page. Publish it whenever a server you inspect declares `domain`. Nothing in v1 corresponds to it. - The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. - **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. - The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. - `:latest` now points at v2. To stay on the v1 image, pin its exact version tag (`:1.0.1`). ## Troubleshooting **"`--config` errors that my file doesn't exist, but v1 created it."** v1 never created config files either — but v2's _default_ path is a catalog it will create. If you want a file created on demand, pass it as `--catalog`, not `--config`. **"`--server` is ignored."** It selects only under `--cli`. Web warns and ignores it; the TUI errors on it as unknown. **"My CI step started failing after upgrading."** Most likely exit code `5`: a `tools/call` returning `isError: true` now exits non-zero. Check `2>&1 | tail -1 | jq .error`. **"The Inspector connects to a different server than I named."** Check flag order — under `--cli` the target must precede all flags, or it is silently dropped in favor of the catalog. **"`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 port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). **"One particular App's frame is blank, while other Apps render fine."** That App declares `_meta.ui.domain`, so it is served from the **third** port (`MCP_APP_ORIGIN_PORT`, `6278` by default) rather than from the sandbox — publish or forward that port too. Expect no console warning: the backend published the document successfully, so from its side nothing failed, and the browser's inability to load a cross-origin URL is not observable from the page. The neighbouring symptom is different — an App that *renders* and has its requests rejected by its own backend is running on the opaque-origin fallback, which means publication itself failed (no listener, or a port that never bound) and the console **does** carry a warning naming `_meta.ui.domain`. See [MCP App dedicated origins](../clients/web/README.md#mcp-app-dedicated-origins-metauidomain). **"The page never loads inside a VS Code dev container."** Fixed in the release carrying #1951 — upgrade; on an older version, work around it with `HOST=127.0.0.1`. Before that fix the default `HOST=localhost` bound IPv6 loopback **only** on Linux, so anything connecting over IPv4 `127.0.0.1` was refused — which is what a dev container's port forwarder does. The terminal printed a normal banner while the browser spun forever; the failure is a **hang** rather than a connection error because the forwarder accepts on the host side before failing to connect inward. The same fix covers an `ssh -L 6274:127.0.0.1:6274` tunnel and a container healthcheck. It does **not** describe the official Docker image (it binds `0.0.0.0`, so it was never affected) or a plain `docker run -p` against a loopback bind, which is unreachable across the network namespace for an unrelated reason. **"`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). **"I need v1 back."** `npx @modelcontextprotocol/inspector@v1-latest`. ## Related - [MCP server configuration](./mcp-server-configuration.md) — the full `--catalog` / `--config` / ad-hoc model. - [CLI README](../clients/cli/README.md) · [TUI README](../clients/tui/README.md) · [web README](../clients/web/README.md) - [Reviewing an MCP App](./mcp-app-review.md) — the CLI-first App review recipe.