From 6650c0b525c8d24147d8a8f7fde3bb9562176d23 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 7 Sep 2026 09:44:33 -0400 Subject: [PATCH 1/2] docs: ALLOWED_ORIGINS recipe for a *.localhost proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2289 Running the Inspector behind a reverse proxy on a *.localhost name works today, but only with ALLOWED_ORIGINS set, and nothing said so or showed the shape. Raised by the reporter of #1944, whose whole service cluster uses those names. Three things the recipe has to carry, all learned the expensive way: - ALLOWED_ORIGINS REPLACES the default list rather than merging, so an entry of only the proxy origin silently breaks browsing at localhost:PORT. The loopback trio has to be listed too. - The failure is confusing rather than obvious. A same-origin GET carries no Origin header, so the page loads and only the POSTs that add or connect a server are rejected — which reads as a connection problem rather than a configuration one. - Only the browser resolves *.localhost for free. Chrome and Firefox map those names to loopback per RFC 6761; the OS resolver on macOS does not and Safari does not at all, so an MCP *server* on such a host still needs /etc/hosts or dnsmasq — the Node backend is what dials it — and with OAuth is refused by the SDK regardless (typescript-sdk#2591). Documented rather than changing the default: see #2282 for why ~1,100 lines of origin and CSP code was a bad trade for removing one env var. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju Signed-off-by: cliffhall --- clients/web/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/clients/web/README.md b/clients/web/README.md index 350009950..e2fc06e3d 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -394,6 +394,19 @@ Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (` 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. +**Running the Inspector behind a `*.localhost` proxy.** A common local-dev shape gives each service a friendly name — `my-api.localhost`, `my-app.localhost`, `inspector.localhost` — instead of a set of ports. The Inspector works there, but the origin allow-list does not include those names by default, so set it explicitly: + +```sh +ALLOWED_ORIGINS=http://inspector.localhost,http://localhost:6274,http://127.0.0.1:6274,http://[::1]:6274 \ + mcp-inspector --web +``` + +⚠️ **List the loopback forms too, not just your proxy origin.** `ALLOWED_ORIGINS` **replaces** the default list rather than merging with it, so an entry of only `http://inspector.localhost` silently breaks browsing at `http://localhost:6274`. Add the port to the proxy origin if it is not on `:80`. The MCP Apps sandbox `frame-ancestors` is derived from the same list, so one entry covers the Apps tab as well. + +Without this you get a confusing failure rather than an obvious one: the page loads (a same-origin `GET` carries no `Origin` header, so it never reaches the guard) and only the **POSTs** that add or connect a server are rejected with a 403 — which reads as a connection problem rather than a configuration one. + +⚠️ **Only the browser resolves `*.localhost` for free.** Chrome and Firefox map those names to loopback internally per [RFC 6761 §6.3](https://www.rfc-editor.org/info/rfc6761/); the OS resolver on macOS does **not**, and Safari does not resolve them at all. That is fine for reaching the Inspector, but an **MCP server** URL on such a host is dialled by the Inspector's Node backend, so it still needs an `/etc/hosts` entry or dnsmasq. Note also that an MCP server on a `*.localhost` host **using OAuth** is currently refused by the SDK, which exempts only `localhost`, `127.0.0.1` and `::1` from its TLS requirement — see [typescript-sdk#2591](https://github.com/modelcontextprotocol/typescript-sdk/issues/2591). + ### Hosting on a network The guard blocks only the **wildcard** all-interfaces addresses. Binding a **specific** IP or hostname is allowed with no opt-in — that's a single, deliberate exposure, unlike the wildcard which binds every interface at once (the pattern DNS-rebinding exploits). To serve the Inspector on a LAN or the internet: From 0acac24cda99c18c42aac24423f0f02850226597 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 7 Sep 2026 10:00:42 -0400 Subject: [PATCH 2/2] docs: every state-changing request 403s, not just the POSTs (review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The troubleshooting note said only the POSTs that add or connect a server are rejected. Verified otherwise: core/react/useServers.ts sends PUT for saving and reordering and DELETE for removing, the origin middleware is method-agnostic, and browsers attach Origin to anything that is not a GET/HEAD. So settings saves, reorders and deletes fail identically. That matters for a troubleshooting doc — someone whose *save* silently fails would not have recognised themselves in the old wording. The POSTs stay as the most visible examples. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju Signed-off-by: cliffhall --- clients/web/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/web/README.md b/clients/web/README.md index e2fc06e3d..02d97a926 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -403,7 +403,7 @@ ALLOWED_ORIGINS=http://inspector.localhost,http://localhost:6274,http://127.0.0. ⚠️ **List the loopback forms too, not just your proxy origin.** `ALLOWED_ORIGINS` **replaces** the default list rather than merging with it, so an entry of only `http://inspector.localhost` silently breaks browsing at `http://localhost:6274`. Add the port to the proxy origin if it is not on `:80`. The MCP Apps sandbox `frame-ancestors` is derived from the same list, so one entry covers the Apps tab as well. -Without this you get a confusing failure rather than an obvious one: the page loads (a same-origin `GET` carries no `Origin` header, so it never reaches the guard) and only the **POSTs** that add or connect a server are rejected with a 403 — which reads as a connection problem rather than a configuration one. +Without this you get a confusing failure rather than an obvious one: the page loads, because a same-origin `GET` carries no `Origin` header and so never reaches the guard, while **every state-changing request is rejected with a 403** — browsers attach `Origin` to anything that is not a `GET`/`HEAD`. Adding and connecting a server (`POST`) are the most visible, but saving settings, reordering and deleting a server (`PUT`/`DELETE`) fail the same way. The result reads as a connection problem rather than a configuration one. ⚠️ **Only the browser resolves `*.localhost` for free.** Chrome and Firefox map those names to loopback internally per [RFC 6761 §6.3](https://www.rfc-editor.org/info/rfc6761/); the OS resolver on macOS does **not**, and Safari does not resolve them at all. That is fine for reaching the Inspector, but an **MCP server** URL on such a host is dialled by the Inspector's Node backend, so it still needs an `/etc/hosts` entry or dnsmasq. Note also that an MCP server on a `*.localhost` host **using OAuth** is currently refused by the SDK, which exempts only `localhost`, `127.0.0.1` and `::1` from its TLS requirement — see [typescript-sdk#2591](https://github.com/modelcontextprotocol/typescript-sdk/issues/2591).