Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c576b96
feat: accept *.localhost origins, and stop reporting a terminal OAuth…
cliffhall Sep 7, 2026
ee1143c
Merge branch 'v2/main' into v2/feat/1944-localhost-subdomains
cliffhall Sep 7, 2026
090059a
fix: address Copilot review round 1
cliffhall Sep 7, 2026
90d85de
fix: address Copilot review round 2
cliffhall Sep 7, 2026
e39e342
fix: address Copilot review round 4
cliffhall Sep 7, 2026
7112062
Merge branch 'v2/main' into v2/feat/1944-localhost-subdomains
cliffhall Sep 7, 2026
5af2eee
fix: tear down the client on the terminal connect arm (Copilot review…
cliffhall Sep 7, 2026
54b2c5b
fix: address Copilot review round 6
cliffhall Sep 7, 2026
55d11fa
Merge branch 'v2/main' into v2/feat/1944-localhost-subdomains
cliffhall Sep 7, 2026
2e41d49
fix: address Copilot review round 7
cliffhall Sep 7, 2026
8553b62
fix: report a terminal token-endpoint refusal from the banner action …
cliffhall Sep 7, 2026
e1f7817
fix: admit *.localhost in the sandbox proxy's referrer check (round 9)
cliffhall Sep 7, 2026
6810c73
fix: classify the satisfied-challenge connect retry (round 10)
cliffhall Sep 7, 2026
5f9327f
docs: the notice is non-expiring, not non-dismissing (round 11)
cliffhall Sep 7, 2026
3874ae4
fix: do not unmap IPv4-mapped IPv6 in explicit ALLOWED_ORIGINS (round…
cliffhall Sep 7, 2026
d5bfb4c
fix: use canonicalOriginHost for the CLI handoff link (round 15)
cliffhall Sep 7, 2026
6699447
fix: scope the root-dot normalization, and reject strictPort without …
cliffhall Sep 7, 2026
010cda1
fix: one rule for the root dot, and validate strictPort's type (round…
cliffhall Sep 7, 2026
8872684
docs: reattach the canonicalOriginHost JSDoc, and tidy a stale commen…
cliffhall Sep 7, 2026
a4329ba
fix: reject every strictPort config that cannot honor the flag (round…
cliffhall Sep 7, 2026
8086904
fix: scope every banner clear to its own server, and stop leaking the…
cliffhall Sep 7, 2026
924085d
fix: preserve nested origins, and admit a *.localhost bind host (roun…
cliffhall Sep 7, 2026
83ba48b
Merge branch 'v2/main' into v2/feat/1944-localhost-subdomains
cliffhall Sep 7, 2026
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
1 change: 1 addition & 0 deletions .claude/skills/test-servers/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ usually looks like a missing capability rather than an error.
| A tool result's `structuredContent` section | `structured-output-http.json` (legacy) |
| RFC 6570 resource-template expansion | `rfc6570-templates-http.json` |
| OAuth token revocation on clear | `oauth-revocation-http.json` (legacy) |
| A token endpoint the SDK refuses (SEP-2207) | `oauth-insecure-token-endpoint-http.json` (legacy) |
| Cancelling a call mid-flight | `cancellation-modern-http.json` (modern) |

## Adding a config or preset
Expand Down
22 changes: 22 additions & 0 deletions clients/cli/__tests__/stored-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,28 @@ describe("--print-handoff", () => {
expect(out.deepLink.startsWith("http://127.0.0.1:16274/?")).toBe(true);
});

it("drops a root FQDN dot from the deep-link host", async () => {
// `HOST=localhost.` binds loopback, but the web server's default allow-list
// is derived through `canonicalOriginHost` and so contains
// `http://localhost:PORT`. A dotted link would load the page and then have
// its autoConnect POST 403'd on the mismatched `Origin` — the page works,
// the connection silently does not (#2280 review round 15).
const result = await runCli(
["--print-handoff", "--server-url", "https://x.example/mcp"],
{
env: {
MCP_INSPECTOR_API_TOKEN: "tok123",
HOST: "localhost.",
CLIENT_PORT: "16274",
},
},
);
expectCliSuccess(result);
const out = JSON.parse(result.stdout) as { deepLink: string };
expect(out.deepLink.startsWith("http://localhost:16274/?")).toBe(true);
expect(out.deepLink).not.toContain("localhost.:");
});

it("advertises localhost in the deep link for a wildcard HOST", async () => {
// 0.0.0.0 is allow-listed so it connects, but the deep link is handed to a
// human — advertise localhost like the web banner does.
Expand Down
9 changes: 7 additions & 2 deletions clients/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type { JsonValue } from "@inspector/core/mcp/index.js";
import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js";
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
import {
canonicalUrlHost,
canonicalOriginHost,
isAllInterfacesHost,
} from "@inspector/core/node/hostUrl.js";
import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js";
Expand Down Expand Up @@ -512,9 +512,14 @@ function buildHandoff(
// wildcard bind (like the web banner/sandbox URL) rather than the awkward
// http://0.0.0.0 / http://[::] — both are allow-listed, but neither is a nice
// URL to click; otherwise use the canonical host so it matches the allow-list.
// `canonicalOriginHost`, not `canonicalUrlHost`: the web server's default
// allow-list is derived through the former, so a root-dotted `HOST=localhost.`
// would otherwise produce a link whose page loads while its auto-connect API
// request carries the dotted `Origin` and is 403'd — the invariant this
// comment claims, quietly broken.
const linkHost = isAllInterfacesHost(host)
? "localhost"
: canonicalUrlHost(host);
: canonicalOriginHost(host);
const clientPort = process.env.CLIENT_PORT || "6274";
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
// The dedicated app origin (#2056). Forwarded alongside the other two: an App
Expand Down
6 changes: 4 additions & 2 deletions clients/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ 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.

**`*.localhost` origins are accepted by default** (#1944). When the allow-list is the derived default *and* the bind host serves loopback (a loopback address, or an all-interfaces wildcard), the backend additionally accepts any `http://` or `https://` origin whose host ends in `.localhost`, **at any port** — `http://mcp.localhost`, `https://tenant.app.localhost:8443`. That covers the common local-dev shape where a reverse proxy on a `*.localhost` name fronts the Inspector, and it is the same default Vite, Django and Rails ship in their own host allow-lists. The suffix is [reserved to the loopback interface by RFC 6761 §6.3](https://www.rfc-editor.org/info/rfc6761/) and is not publicly registrable, so it cannot be obtained by an attacker the way a rebound domain can; `/api/*` still requires the bearer token regardless. Two things follow from the gating: setting `ALLOWED_ORIGINS` **turns this off** (that list replaces the default and is honoured exactly — add your `*.localhost` origins to it explicitly), and binding a *specific* non-loopback address turns it off too (a browser at `foo.localhost` resolves to `127.0.0.1` and never reaches such a process). Note that only the **browser** resolves these names for free: Chrome and Firefox map them to loopback internally, but the OS resolver on macOS does not, so an MCP **server** URL on a `*.localhost` host still needs a `/etc/hosts` entry or dnsmasq — the Inspector's backend is what dials it. Safari does not resolve them at all.

### 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:
Expand All @@ -402,9 +404,9 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe
- **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://<bind-host>:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`.
- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 127.0.0.1:6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`.

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`.
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`. A `*.localhost` name needs nothing there: Vite's default `allowedHosts` already accepts `localhost` and everything under `.localhost`, which is why only the origin allow-list above had to change.

**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.
**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. (A `*.localhost` embedder is fine — `*.localhost` *is* a legal CSP host-source, the sandbox's `frame-ancestors` admits it whenever the origin allow-list does, and the proxy page's own referrer check admits it too. That check is a **third** gate, enforced in `static/sandbox_proxy.html`, which ships as static bytes and so cannot read `ALLOWED_ORIGINS` — it admits the two loopback literals and the reserved `*.localhost` suffix and nothing else, which is why hosting the Apps tab on any *other* custom origin still needs the edit its error message suggests.) 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
28 changes: 24 additions & 4 deletions clients/web/server/app-origin-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
import { createServer, type Server } from "node:http";
import { randomBytes } from "node:crypto";
import {
canonicalUrlHost,
canonicalOriginHost,
isAllInterfacesHost,
} from "../../../core/node/hostUrl.ts";
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
Expand Down Expand Up @@ -162,6 +162,14 @@ export interface AppOriginControllerOptions {
* loopback, exactly as the sandbox proxy's own `frame-ancestors` does.
*/
embedderOrigins?: string[];
/**
* Also admit `*.localhost` embedders (#1944), matching the backend's origin
* guard and the sandbox proxy's own `frame-ancestors`. The app document is
* framed by the sandbox proxy, not by the inspector page directly — but the
* proxy is reached through the same browsing context, so the three have to
* agree or the innermost frame blanks.
*/
allowLocalhostSubdomains?: boolean;
}

/** A document handed to {@link AppOriginController.publish}. */
Expand Down Expand Up @@ -237,7 +245,12 @@ export function createAppOriginController(
// Same defaulting rationale as the sandbox controller: never the *name*
// `localhost`, which resolves to a single address family and would put this
// listener on a different family than the web server (#1951).
const { port, host = DEFAULT_BIND_HOST, embedderOrigins } = options;
const {
port,
host = DEFAULT_BIND_HOST,
embedderOrigins,
allowLocalhostSubdomains,
} = options;
let server: Server | null = null;
let origin: string | null = null;

Expand All @@ -256,7 +269,9 @@ export function createAppOriginController(
documents.delete(id);
}

const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins);
const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins, {
allowLocalhostSubdomains,
});

/** Drop everything past its TTL. Cheap: the map is bounded by MAX_DOCUMENTS. */
function evictExpired(now: number): void {
Expand Down Expand Up @@ -360,7 +375,12 @@ export function createAppOriginController(
// A wildcard bind isn't reachable as `http://0.0.0.0:PORT`, but it
// does serve loopback — advertise `localhost` there, and otherwise
// the same canonical host the origin allow-list emits.
const canonicalHost = canonicalUrlHost(host);
// `canonicalOriginHost`, not `canonicalUrlHost`: this URL's origin is
// named in a `frame-ancestors` directive, and a root-dotted host is
// not a valid CSP host-source — so `HOST=localhost.` would advertise
// a reachable URL whose origin the browser silently drops, blanking
// the frame. The bind host above is untouched.
const canonicalHost = canonicalOriginHost(host);
const urlHost = isAllInterfacesHost(canonicalHost)
? "localhost"
: canonicalHost;
Expand Down
Loading