Skip to content

feat: accept *.localhost origins, and stop reporting a terminal OAuth refusal as retryable - #2282

Closed
cliffhall wants to merge 23 commits into
v2/mainfrom
v2/feat/1944-localhost-subdomains
Closed

feat: accept *.localhost origins, and stop reporting a terminal OAuth refusal as retryable#2282
cliffhall wants to merge 23 commits into
v2/mainfrom
v2/feat/1944-localhost-subdomains

Conversation

@cliffhall

@cliffhall cliffhall commented Sep 7, 2026

Copy link
Copy Markdown
Member

Paused pending a scope question on #1944

Holding this for a reply from the reporter — #1944 (comment).

Closes #1944
Closes #2280

*.localhost support, split into the half we own and the half we don't — plus the presentation bug that made the half we don't own hard to diagnose.

The research behind the split is written up on #1944; the short version is that two different gates were both in play, with two different owners.

Surface Owner Before After
A. OAuth token endpoint on http://*.localhost SDK (assertSecureTokenEndpoint) Terminal refusal, reported as a retryable auth failure Still refused — but reported honestly. Fix tracked upstream (typescript-sdk#2591, PR #2597, which I've asked for review on)
B. Browsing the Inspector at http://mcp.localhost Us 403 unless ALLOWED_ORIGINS is set Accepted by default
C. Vite --dev host check Vite Already fine — its default allowedHosts covers .localhost unchanged

B — *.localhost origins are accepted by default

When the origin allow-list is the derived default and the bind host serves loopback, /api/* now also accepts any http(s) origin whose host ends in .localhost, at any port. The MCP Apps sandbox and app-origin frame-ancestors admit the same set, so the two layers cannot disagree and blank the Apps frame.

This is the default Vite, Django and Rails all ship in their own host allow-lists. It does not weaken the DNS-rebinding guard: rebinding works by pointing a name the attacker controls at 127.0.0.1, and .localhost is reserved by RFC 6761 §6.3 and not publicly registrable, so there is no such name to obtain. /api/* still requires the bearer token regardless.

Two deliberate gates keep the widening narrow, both tested:

  • Setting ALLOWED_ORIGINS turns it off. That list replaces the default and is documented as honoured exactly; a list stating which origins are allowed must not silently gain entries.
  • A specific non-loopback bind turns it off. A browser at foo.localhost resolves to 127.0.0.1 and never reaches such a process, so admitting the origin would be a no-op that only made the allow-list harder to reason about.

allowedOrigins stays a list of literal origins compared exactly. The widening is a separate, explicitly-named allowLocalhostSubdomainOrigins flag rather than a wildcard entry in that list — the list is also read by the CSP builder, and a value the two layers interpret differently is precisely the split-behaviour hazard ALLOWED_ORIGINS already rejects wildcards to avoid.

A / #2280 — a terminal error stops pretending to be retryable

InsecureTokenEndpointError appeared nowhere in our source, so it fell through to the generic auth path and rendered a "Re-authentication required" banner with a Re-authenticate button. That button could never work: the error does not extend OAuthError, and auth() special-cases it to rethrow rather than start a fresh /authorize redirect.

It is now recognized (core/auth/insecureTokenEndpoint.ts, mirroring issuerBinding.ts's brand-plus-name classifier) and surfaced as the configuration error it is — naming the endpoint and both ways out, with no action affordance. Claimed at the single showReAuthBanner funnel plus the two connect-path arms, so a future caller gets the right behavior by default rather than by remembering.

This is presentational only. Making a *.localhost token endpoint actually work has to land in the SDK — the assertion runs inside executeTokenRequest, takes no options, and there is no hook we could reach. That is the same conclusion #1911 reached.

Reproducing it

New fixture test-servers/configs/oauth-insecure-token-endpoint-http.json, documented in docs/test-servers.md. Its issuerUrl is http://localhost.:8091 — the root-anchored spelling of localhost, which every resolver sends to loopback (so the flow runs for real) but which is none of the SDK's three exempt literals (so the exchange is refused). It reproduces the class without needing an /etc/hosts entry or dnsmasq.

One caveat worth stating

Only the browser resolves *.localhost for free. Chrome and Firefox map it to loopback internally; the OS resolver on macOS does not (dns.lookup('foo.localhost')ENOTFOUND), and Safari does not resolve it at all. So an MCP server URL on a *.localhost host still needs a hosts entry or dnsmasq — the Inspector's backend is what dials it. This is called out in clients/web/README.md.

Testing

npm run local:gate passes. New coverage: the two host predicates including the app.localhost.evil.com suffix trap, the origin guard (allowed / still-blocked / preflight symmetry, with the flag off as the control), the CSP directive in both controllers, the config gating, the error classifier, and the notice helper.

Screenshots

#1944*.localhost origin support

The proof is the origin table, not the screenshots. A screenshot cannot show this: the *.localhost part lives in the address bar, which the capture tool does not include, and the card in the image shows the MCP server URL rather than the Inspector's own origin. So here is the same POST sent to each build with an explicit Origin, no browser involved:

Origin sent before (v2/main) after (this PR)
http://mcp.localhost:PORT 403 200
http://tenant.app.localhost:PORT 403 200
http://localhost:PORT — positive control 200 200
http://evil.com — negative control 403 403

The controls are what make it a result: localhost returns 200 on both, so a 200 means "reached the handler" rather than "the guard is off"; evil.com stays 403 on both, so the guard still works. Only the two *.localhost rows move.

The screenshots below corroborate it end to end. Both browse the Inspector at http://mcp.localhost:PORT (Chromium resolves *.localhost to loopback per RFC 6761 with no hosts entry) and auto-connect via deep link. Note the page load alone proves nothing — Chrome omits Origin on same-origin GETs, so the guard is only reached by the POSTs that add and connect a server.

Before (v2/main) — the deep-link server never appears. POST /api/servers is rejected: 1 × 403, data-status="disconnected". Note there is no error on screen; the failure is silent, which is what makes it expensive to diagnose.

before

After — the deep-link card is present and green Connected, with live protocol traffic in the sidebar. 34 API requests, 0 × 403, data-status="connected", at window.location.origin === "http://mcp.localhost:6293".

after

#2280 — the terminal OAuth notice

Captured against the new oauth-insecure-token-endpoint-http.json fixture, driving the real OAuth flow end to end.

Before — the "Re-authentication required" banner, whose Re-authenticate button re-runs the same flow to the same refusal. The only text is the raw SDK message, which names the three exempt literals and nothing actionable. Note the card is also flagged red Failed with the monitoring sidebar pulled open, treating a configuration error as a failed connect attempt.

before

After — a non-dismissing notice naming the endpoint and both ways out, with no action affordance and no red card.

after

… refusal as retryable

Closes #1944
Closes #2280

#1944 turned out to span two gates with two different owners, so this does
the half we own and reports the half we do not.

Ours: when the origin allow-list is the derived default and the bind host
serves loopback, /api/* now also accepts any http(s) origin whose host ends
in .localhost, at any port. The MCP Apps sandbox and app-origin
frame-ancestors admit the same set, so the two layers cannot disagree and
blank the Apps frame. That is the default Vite, Django and Rails all ship.
Setting ALLOWED_ORIGINS turns it off (that list replaces the default and is
documented as honoured exactly), as does a specific non-loopback bind.

allowedOrigins stays a list of literal origins compared exactly; the
widening is a separately named flag rather than a wildcard entry, because
the list is also read by the CSP builder and a value the two layers read
differently is the split-behaviour hazard ALLOWED_ORIGINS already rejects
wildcards to avoid.

Theirs (#2280): InsecureTokenEndpointError appeared nowhere in our source,
so it fell through to the generic auth path and rendered a
"Re-authentication required" banner whose button could never work - the
error does not extend OAuthError and auth() rethrows it rather than
redirecting. It is now recognized and surfaced as the configuration error
it is, naming the endpoint and both ways out, with no action affordance.
Making such an endpoint actually work has to land in the SDK
(typescript-sdk#2591) - the assertion takes no options and there is no hook
we could reach, the same conclusion #1911 reached.

Adds oauth-insecure-token-endpoint-http.json to reproduce it: its issuer is
http://localhost.:8091, the root-anchored spelling every resolver sends to
loopback but which is none of the SDK's three exempt literals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Wrapped SDK errors, trailing-dot hosts, fixture port relocation, and key hook coverage remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds default *.localhost origin support and presents insecure OAuth token endpoints as terminal configuration errors.

Changes:

  • Extends origin validation and MCP Apps CSP handling.
  • Adds dedicated insecure-token-endpoint detection and notifications.
  • Adds fixtures, documentation, and tests.
File summaries
File Description
test-servers/configs/oauth-insecure-token-endpoint-http.json Adds OAuth refusal fixture.
docs/test-servers.md Documents the fixture.
core/node/hostUrl.ts Adds localhost-subdomain predicates.
core/mcp/remote/node/server.ts Widens optional origin validation.
core/auth/oauthUx.ts Adds terminal-error copy.
core/auth/insecureTokenEndpoint.ts Classifies SDK errors.
clients/web/src/utils/oauthUx.ts Re-exports new copy helpers.
clients/web/src/test/integration/server/web-server-config.test.ts Tests configuration gates.
clients/web/src/test/integration/server/server-token-injection.test.ts Updates server fixture config.
clients/web/src/test/integration/server/server-auto-open.test.ts Updates server fixture config.
clients/web/src/test/integration/server/sandbox-controller.test.ts Tests widened CSP.
clients/web/src/test/integration/server/app-origin-controller.test.ts Tests app-origin CSP.
clients/web/src/test/integration/mcp/remote/transport.test.ts Tests origin enforcement.
clients/web/src/test/core/node/hostUrl.test.ts Tests host predicates.
clients/web/src/test/core/auth/oauthUx.test.ts Tests notification copy.
clients/web/src/test/core/auth/insecureTokenEndpoint.test.ts Tests error classification.
clients/web/src/lib/insecureTokenEndpointNotice.ts Adds terminal notification helper.
clients/web/src/lib/insecureTokenEndpointNotice.test.ts Tests notification behavior.
clients/web/src/hooks/useOAuthRecovery.ts Handles callback and banner failures.
clients/web/src/hooks/useConnectionLifecycle.ts Handles connection-time failures.
clients/web/server/web-server-config.ts Derives localhost widening policy.
clients/web/server/vite-hono-plugin.ts Propagates policy in development.
clients/web/server/server.ts Propagates policy in production.
clients/web/server/sandbox-controller.ts Widens sandbox frame ancestors.
clients/web/server/app-origin-controller.ts Widens app frame ancestors.
clients/web/README.md Documents localhost behavior.
.claude/skills/test-servers/SKILL.md Catalogs the new fixture.
Review details
  • Files reviewed: 27/27 changed files
  • Comments generated: 8
  • Review effort level: Balanced

Comment thread clients/web/server/sandbox-controller.ts
Comment thread clients/web/server/web-server-config.ts
Comment thread core/auth/insecureTokenEndpoint.ts
Comment thread test-servers/configs/oauth-insecure-token-endpoint-http.json
Comment thread clients/web/src/hooks/useConnectionLifecycle.ts
Comment thread clients/web/src/hooks/useOAuthRecovery.ts
Comment thread core/auth/oauthUx.ts Outdated
Comment thread docs/test-servers.md Outdated
All eight findings were legitimate; two rested on claims about existing
behavior that I verified before acting (issuerBinding really does walk
cause/data.cause; findAvailablePort really does walk ports on EADDRINUSE).

- isLocalhostSubdomainHost no longer strips a root FQDN dot, so
  `app.localhost.` is rejected. Accepting it split the two layers: CSP's
  `*.localhost` host-source cannot match the root-dotted form, so /api/*
  would answer an embedder whose MCP Apps frame the sandbox CSP then blanks.
- allowLocalhostSubdomainOriginsFor now strips that dot, so
  `HOST=localhost.` is read as the loopback bind it is. The two run opposite
  ways deliberately and both say why: one canonicalizes an operator-typed
  bind host against the OS resolver, the other matches a browser Origin that
  must also be expressible as a CSP host-source.
- The classifier walks cause / data.cause chains and returns the matched
  inner shape, mirroring findIssuerBindingFailure including its seen set. A
  top-level-only check missed the connect and refresh paths, where era
  negotiation and the transport wrappers bury the rejection.
- The fixture sets transport.strictPort, a new opt-out from the port walk,
  so it fails loudly instead of announcing one port while its OAuth issuer
  names another.
- Copy and docs no longer call these hosts "not loopback". They are loopback
  by RFC 6761 and by every resolver on the machine; what they are outside is
  the SDK's three-literal exemption, and the old wording sent readers to
  debug their networking instead of their configuration.
- Hook-level tests for both new branches in useConnectionLifecycle and the
  callback branch in useOAuthRecovery, asserting position (no banner, no
  failed-server flag, no generic toast) rather than just the helper, plus
  wrapped-cause cases and live strictPort coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 1 — all eight addressed

Every finding was legitimate. Two rested on claims about existing behavior, and I checked both against the repo before acting rather than taking them on faith — both held: findIssuerBindingFailure really does walk cause / data.cause, and findAvailablePort really does recurse on EADDRINUSE.

# Finding Resolution
1 Root-dotted origin accepted but unmatchable by CSP isLocalhostSubdomainHost no longer strips the dot — app.localhost. is now rejected, so both layers agree
2 HOST=localhost. disabled the widening Strips the dot before the bind-host lookup
3 Classifier only saw a top-level error Now findInsecureTokenEndpoint, walking both links with a seen set, returning the inner shape
4 Fixture port could relocate away from its issuer New transport.strictPort; the fixture fails rather than relocating
5 Connect-path branches untested at the hook Three hook cases asserting position, not just the helper
6 Callback branch untested at the hook Two hook cases: no banner, no failed-server flag
7 Copy called a loopback host "not loopback" Reworded to "outside the SDK's loopback exemption", with the reason recorded
8 Docs contradicted the fixture explanation Same rewording, now consistent

Two things worth reading rather than skimming:

#1 and #2 pull in opposite directions on purpose. One strips the root dot, the other now refuses to. They answer different questions: allowLocalhostSubdomainOriginsFor canonicalizes an operator-typed bind host and only has to agree with the OS resolver, which treats localhost. as localhost; isLocalhostSubdomainHost matches a browser-sent Origin that must also be expressible as a CSP host-source, and CSP has no *.localhost. to offer. Each now carries a comment pointing at the other, so the next reader does not "fix" one into the other.

#7 was the sharpest catch — the copy contradicted this PR's own argument. The whole point is that *.localhost and the localhost. fixture are loopback and the exemption is too narrow; telling a user their loopback host is not loopback sends them to debug their networking instead of their configuration. A test now pins the phrase.

npm run format + npm run local:gate green on the fixed tree (and re-verified after the v2/main merge). New coverage: 5 classifier cases for the cause chains, 5 hook cases across both paths, 3 live strictPort cases, and the moved/added host-predicate cases.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Command-scoped OAuth failures bypass the terminal classifier, and root-dotted localhost remains inconsistent with the Apps CSP.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 34/34 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread clients/web/server/web-server-config.ts Outdated
Comment thread clients/web/src/hooks/useOAuthRecovery.ts Outdated
Both findings were real, and both premises checked against the code first.

- HOST=localhost. advertised http://localhost.:PORT. That passed the
  exact-match API guard and then blanked the MCP Apps frame, because a
  root-dotted host is not a valid CSP host-source and *.localhost does not
  cover bare localhost. — the same split round 1 fixed one layer up, still
  live one line away. The banner and defaultAllowedOrigins now share a
  canonicalOriginHost helper that drops the root dot, so banner ⊆
  allowedOrigins holds through the normalization rather than in spite of it.
  The bind host is untouched: HOST still binds exactly what was typed, and
  only the browser-facing spelling changes. Deliberately not folded into
  canonicalUrlHost, which isLocalhostSubdomainHost needs to keep the dot for
  the round-1 reason.

- runWithCommandAuthRecovery was not the funnel I claimed. It rethrows every
  non-AuthRecoveryRequiredError, so a mid-session silent refresh against an
  unusable token endpoint reached runCommandInBackground and either showed
  the raw SDK text under a generic title or, where the panel owns reporting,
  was swallowed and left the command looking like it did nothing. It is now
  claimed there too, taking the same undefined exit the unsatisfied-recovery
  branch already uses.

Tests: the advertised-origin trio and the frame-ancestors directive for
HOST=localhost., the banner-subset invariant, and three command-path cases
covering the awaited form and both background forms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 2 — both addressed

Both findings were real, and both were places my round-1 reasoning was incomplete rather than merely unfinished. I verified each against the code before changing anything.

# Finding Resolution
1 HOST=localhost. still advertised a root-dotted origin Banner and defaultAllowedOrigins now share canonicalOriginHost, which drops the root dot; bind host untouched
2 The command path never reached the classifier Classified in runWithCommandAuthRecovery's rejection path, taking its existing undefined exit

On #1 — round 1 only closed one layer. I fixed the predicate so app.localhost. is rejected, and then left defaultAllowedOrigins emitting http://localhost.:PORT one function away. CSP_HOST_SOURCE passes that string through into frame-ancestors, where the browser drops it as an invalid host-source, so the advertised URL would pass the API guard and blank MCP Apps — exactly the failure I had just written a doc comment about. The normalization is shared between the banner and the allow-list specifically so banner ⊆ allowedOrigins survives it, and it is deliberately not folded into canonicalUrlHost, which the round-1 predicate needs to keep the dot.

On #2 — my round-1 reply asserted something false. I described showReAuthBanner as "the single funnel every re-auth banner goes through", and for the command path that is simply not true: runWithCommandAuthRecovery rethrows every non-AuthRecoveryRequiredError, so a mid-session silent refresh never got near it. Worth stating plainly, because the code comment I wrote made the wrong claim durable. Of the two downstream outcomes the silent one was worse — at a call site whose panel owns reporting, the rejection was swallowed and the command just appeared to do nothing.

npm run format + npm run local:gate green. Six new tests: three configuration cases for the root-dotted host (advertised trio, banner-subset invariant with hostname asserted unchanged, no root-dotted CSP source) and three command-path cases (awaited, background with a title, background without one).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Some OAuth recovery paths still present or retry terminal failures, and the strict-port test leaks global state.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

clients/web/src/hooks/useConnectionLifecycle.ts:646

  • The connected-session Re-authenticate path still catches inspectorClient.authenticate() failures as the generic “OAuth authorization failed” toast. The SDK’s auth() refresh branch rethrows InsecureTokenEndpointError, so clicking an existing banner after the endpoint becomes insecure bypasses this new terminal notice. Apply the classifier in onReauthenticateFromBanner’s catch before the generic toast.
        // SEP-2207 (#2280): a token endpoint the SDK will not post credentials
        // to. Terminal, so it gets the same treatment as the EMA arm above
        // rather than the generic "OAuth authorization failed" toast, whose
        // detail line would be the raw SDK text.
        if (showInsecureTokenEndpointNotice(err, target.name)) {
          return;
  • Files reviewed: 34/34 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread clients/web/src/hooks/useOAuthRecovery.ts Outdated
Comment thread clients/web/src/hooks/useOAuthRecovery.ts
Comment thread clients/web/src/test/integration/mcp/strict-port.test.ts Outdated
Comment thread core/auth/oauthUx.ts Outdated
@cliffhall cliffhall linked an issue Sep 7, 2026 that may be closed by this pull request
2 tasks
Four findings, all real. The second is the one that mattered.

- The terminal arm in showReAuthBanner did not clear a banner already on
  screen, so a later terminal failure showed the notice beside a stale
  Re-authenticate button — the affordance this change exists to remove,
  sourced from an earlier failure rather than this one.

- resumePendingReauth re-armed the pending slot on any rejection and toasted
  "will try again". handleAuthChallenge runs the same SDK auth flow, so it
  can raise this terminal error — and re-arming meant every subsequent tab
  focus and reconnect replayed it, forever, under a promise of a retry that
  cannot succeed. Classified before the restore, so it is reported once and
  released. The regression test asserts the second becomeVisible() does not
  call handleAuthChallenge again, which is the actual loop.

- The copy said "plain HTTP". The SDK's check is protocol !== "https:", so a
  mistyped ftp: or ws: endpoint reaches the same notice; it now says "not
  HTTPS" so the diagnosis matches the error's contract.

- strict-port.test.ts nulled the failed server to skip teardown, but start()
  installs the process-global test-server control before it binds and only
  stop() clears it, so the failed instance left that global pointing at
  itself for the rest of the worker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 4 — all four addressed

Round 3 returned zero comments; this round returned four, which is a useful reminder that one clean round is not a verdict.

# Finding Resolution
1 Terminal arm left a stale banner on screen setReAuthBanner(null) before the return
2 Deferred tab recovery re-armed a terminal failure Classified before the restore, so it is reported once and released
3 Copy said "plain HTTP" Now "not HTTPS", matching protocol !== "https:"
4 Strict-port test leaked the process-global server control Instance stays assigned so teardown resets it

#2 is the one that mattered. handleAuthChallenge runs the same SDK auth flow, so it can raise InsecureTokenEndpointError — and resumePendingReauth's catch restores the pending slot on any rejection, because its premise is that the recovery is still owed. For a refusal that can only fail the same way, that is an unbounded loop: every tab focus and every reconnect replays it, each time under a toast promising a retry that cannot succeed. The regression test asserts the loop rather than the toast — after the terminal failure it calls becomeVisible() again and checks handleAuthChallenge has still been called exactly once.

#1 is the same mistake one level out. I removed the dead affordance for the failure in hand and left an identical one on screen from an earlier failure, which is indistinguishable to the user and equally dead.

#3 and #4 are small but both were true. The SDK gates on protocol !== "https:", so an ftp:// endpoint reaches this notice and was being told it was HTTP. And nulling the failed server in the test skipped stop(), which is the only thing that clears the global start() installs before it binds — a cross-test leak from a test whose whole subject is a failed start.

npm run format + npm run local:gate green. Four new tests: the banner-clearing path driven through oauthError events, the no-re-arm loop assertion, the ftp:// scheme phrasing, and the teardown comment guarding #4.

@cliffhall
cliffhall requested a balanced review from Copilot September 7, 2026 03:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The initial connection path leaves the client in error state, still producing the red status and opening monitoring.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 34/34 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread clients/web/src/hooks/useConnectionLifecycle.ts Outdated
… round 5)

InspectorClient.connect() sets its status to "error" and dispatches
statusChange before rethrowing — the only exemption is a connect-auth-
recovery error, which this is not — and InspectorView pins the monitoring
sidebar open on that transition and paints the card red. So returning
without a teardown presented this as the failed connect attempt the notice
explicitly says it is not: careful configuration-error copy on top of the
full failure presentation. The authenticate() arm below already disconnects
for the same reason.

The tests mock connect(), so they never reach that status transition and
could not have caught it; they now assert the teardown, which is the
available proxy.

The EMA arm directly above has the identical gap but predates this branch,
so it is filed as #2284 rather than changed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 5 — addressed

One finding, and a good one. Reviewed against the merged head (7112062).

InspectorClient.connect() sets status = "error" and dispatches statusChange before rethrowing, and InspectorView pins the monitoring sidebar open on that transition. So the terminal connect arm was returning with the client still in the failed-connect presentation — red card, sidebar open — underneath copy that explicitly says this is not a failed attempt. Now disconnects first, as the authenticate() arm already did.

The critique of the test was also correct: connect is mocked, so the suite never reaches the real status transition and could not have caught this. The two connect-path cases now assert the teardown, and the branch comment records why it is load-bearing so it does not get trimmed later as a redundant line.

Filed rather than fixed: the EMA arm immediately above has the identical gap — same configuration-error framing, same missing teardown — but it predates this branch. Changing enterprise-auth behavior inside a *.localhost PR is the wrong place for it, so it is #2284 with the full analysis and the reproduction path.

npm run format + npm run local:gate green, both before and after the v2/main merge.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Terminal failures can clear another server’s banner, and one strict-port test leaks process-global control state.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

clients/web/src/hooks/useOAuthRecovery.ts:404

  • This unconditional clear can erase a valid re-auth banner for another server. Command/deferred recovery is asynchronous, so server A can reject after the user has switched and server B has raised its own banner; the stale A continuation then calls this helper and clears B's state. Pass the failing serverId into this helper and use a functional update that only clears a banner whose serverId matches.
  • Files reviewed: 38/38 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread clients/web/src/test/integration/mcp/strict-port.test.ts Outdated
… test control (round 20)

- The terminal SEP-2207 arms cleared the re-auth banner unconditionally. These
  paths are asynchronous, so server A can reject long after the user switched
  away and server B raised a banner of its own; the stale A continuation then
  erased B's, which was still valid and still actionable. Every clear is now a
  functional update guarded on serverId, which meant widening the exported
  setter's type so consumers can pass an updater at all.

- The strict-port test added in round 19 nulled its failed instance, skipping
  teardown and leaving the process-global test-server control pointing at a
  dead server. That is the same leak round 4 fixed on the EADDRINUSE case; the
  comment there now exists on both so it does not happen a third time.

The coverage gate then caught something better than a threshold miss: the three
new updaters were never *invoked*, because the harness's setReAuthBanner is a
spy that ignores updater functions — so the guard was reaching the line without
being tested. The tests now apply the captured updater and assert both
directions on each arm: clears its own server's banner, spares another's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 20 — both addressed

Finding Where Resolution
Terminal failures could clear another server's banner Suppressed comments Every clear is a functional update guarded on serverId
Strict-port test leaked the process-global control inline Instance stays assigned for teardown

The cross-server bug is a real one and I would not have found it by inspection. These recovery paths are asynchronous: server A can reject long after the user switched away and server B raised its own banner. My unconditional setReAuthBanner(null) then erased B's — still valid, still actionable, and with no way for the user to connect the disappearance to anything they did. All five clears are now guarded on serverId, which required widening the exported setter's type so consumers can pass an updater at all.

The leak is one I had already been taught. Round 4 flagged exactly this on the EADDRINUSE case; I fixed it, then wrote a new test in round 19 that nulls the instance the same way. Both now keep it assigned and carry the comment saying why, since a bare server = null reads as tidy.

The coverage gate caught something better than a threshold miss

useConnectionLifecycle.ts dropped to 87.87% functions. The cause was not a missing test — it was that the three new updaters were never invoked: the harness's setReAuthBanner is a spy that ignores updater functions, so the guard I had just written was reaching the line without being exercised at all.

Nudging coverage would have hidden that. Instead the tests now apply the captured updater and assert both directions on each arm — clears its own server's banner, spares another's — which is the property the finding actually asked for.

npm run local:gate → EXIT=0, zero failures.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The changes span security-sensitive origin validation, CSP enforcement, and multiple asynchronous OAuth recovery paths.

Review details
  • Files reviewed: 38/38 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Origin derivation still mishandles localhost-subdomain bind hosts and nested-origin URL entries.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

clients/web/server/web-server-config.ts:567

  • Rebuilding from parsed changes nested-origin URLs that the existing guard accepts. For example, new URL("blob:https://example.com/id").origin is https://example.com, but this code stores blob://; because that leaves configuredOrigins non-empty, the default is suppressed and every real browser origin is rejected. Normalize the already-derived parsedOrigin instead, preserving the previous new URL(o).origin contract while still stripping a loopback root dot.
        const host = stripLoopbackRootDot(parsed.hostname);
        return parsed.port
          ? `${parsed.protocol}//${host}:${parsed.port}`
          : `${parsed.protocol}//${host}`;
  • Files reviewed: 38/38 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread clients/web/server/web-server-config.ts Outdated
…d 22)

- URL.origin is not always protocol + hostname. A nested-origin URL resolves
  through its inner one — `blob:https://example.com/id` has origin
  `https://example.com` and an EMPTY hostname — so the round-13 rebuild emitted
  `blob://`. Being non-empty that entry survived, suppressed the derived
  default list, and would have 403'd every real browser origin. The rebuild now
  happens only when a loopback root dot actually has to be removed; otherwise
  the parsed origin is returned untouched, preserving the previous
  `new URL(o).origin` contract exactly.

- allowLocalhostSubdomainOriginsFor excluded a bind host that is itself under
  *.localhost, so `HOST=inspector.localhost` allow-listed only its own exact
  origin and still 403'd a sibling alias like `tenant.inspector.localhost` —
  which resolves to the same loopback interface and reaches this very process.
  Excluding it was arbitrary: the whole feature rests on RFC 6761 treating that
  suffix as loopback, and a *.localhost bind that starts is loopback-serving on
  the same premise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 22 — both addressed

Finding Where Resolution
Nested-origin entries (blob:) rebuilt into garbage Suppressed comments Deviate from URL.origin only when a dot must go
*.localhost bind host excluded from the widening inline isLocalhostSubdomainHost added to the predicate

The blob: one is another regression from my round-13 rebuild, and it taught me something about URL I had wrong. I assumed URL.origin was always protocol + hostname [+ port]. It is not: a nested-origin URL resolves through its inner origin, so blob:https://example.com/id has origin https://example.com and an empty hostname. My rebuild therefore emitted blob:// — and because that is non-empty, it suppressed the derived default list and would have rejected every real browser origin. A config that looks like it adds one exotic entry silently disables the whole allow-list.

The fix is to stop rebuilding except when a loopback root dot actually has to be removed. Everywhere else the parsed origin is returned untouched, which preserves the previous new URL(o).origin contract exactly rather than re-deriving it.

The second was arbitrary rather than conservative on my part. The feature rests on RFC 6761 treating .localhost as loopback; admitting those origins on a 127.0.0.1 bind while refusing them on an inspector.localhost bind applies that premise in one direction only. A sibling alias resolving to the same loopback interface, reaching this very process, would get a 403.

npm run local:gate → EXIT=0, zero failures.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-sensitive origin/CSP changes and multi-path OAuth lifecycle handling span 38 files and warrant final human validation.

Review details
  • Files reviewed: 38/38 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It spans security-sensitive origin/CSP policy and multi-path OAuth state handling, warranting final human validation.

Review details
  • Files reviewed: 38/38 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@cliffhall

Copy link
Copy Markdown
Member Author

Review loop closed — two consecutive clean rounds

Rounds 23 and 24 both returned zero inline comments, no Suppressed comments block and no defect named in the body, on the same head commit (924085d). That is the stopping condition; the automated loop is done and this is ready for human review.

What the review actually found

Twenty-four rounds. Fifteen carried at least one real finding. The distribution is the part worth knowing if you run this loop yourself:

  • Ten rounds carried a Suppressed comments block, and it held a genuine defect six times — including the blob: entry that silently disabled the entire origin allow-list, and the cross-server banner clear.
  • Four rounds posted zero inline comments and still named a real bug in the review body — one of them the sandbox-proxy referrer check, without which this PR would have shipped *.localhost support with a visibly broken MCP Apps tab.
  • Rounds 12, 14, 21 and 23 were clean; 13, 15, 16, 17, 19, 20 and 22 each found something. "No comments" was never a reliable terminal signal on this PR.

Where the risk actually concentrated

The *.localhost widening itself is small. Almost every defect came from one decision — routing browser-facing hosts through a new normalization helper — whose consequences surfaced one call site at a time over eight rounds: the banner/allow-list pairing, the opaque-origin guard, the IPv4-mapped rewrite, the CLI handoff, non-loopback absolute names, and finally nested origins. The corrections that stuck were the ones that removed the duplication rather than patching each site (stripLoopbackRootDot as the single rule; one wrapper for every terminal OAuth arm).

For the human reviewer

  1. Four layers must agree on the browser-facing host — origin allow-list, sandbox frame-ancestors, the proxy page's own referrer check, and the CLI handoff link. Three derive from configuration; the proxy's is static and hardcodes the reserved suffix, because it ships as bytes and cannot read ALLOWED_ORIGINS. That asymmetry is the most likely thing to drift.
  2. Six OAuth paths classify the terminal SEP-2207 refusal (connect handshake, authenticate(), the satisfied-challenge retry, the post-redirect callback, command/background, deferred tab resume, and the banner action). They share one wrapper so a seventh cannot silently omit the banner clear.
  3. #2284 is filed rather than fixed here — the EMA config-error arm has the identical missing-teardown gap, and changing enterprise-auth behavior inside a *.localhost PR was the wrong call.

npm run local:gate → EXIT=0, zero failures, on the head being reviewed.

@cliffhall

Copy link
Copy Markdown
Member Author

Added the missing screenshots for #1944

A reviewer pointed out that the screenshots demonstrated nothing about *.localhost. That was correct and worth fixing: the two existing images are for this PR's #2280 half (the terminal OAuth notice, captured against the localhost. fixture), and the headline feature had no visual proof at all. The PR body now labels both pairs by issue.

The new pair browses the Inspector at http://mcp.localhost:PORT and auto-connects to a local test server.

One thing I got wrong on the first attempt, worth recording because it would have produced a misleading screenshot: my initial capture showed 0 × 403 on both arms. Chrome omits the Origin header on same-origin GETs, so simply loading the page never reaches the DNS-rebinding guard at all. The guard is only exercised by the POSTs that add and connect a server. A page-load screenshot would have "passed" identically before and after and proved nothing.

Driving a real connect gives a decisive result:

before (v2/main) after
window.location.origin http://mcp.localhost:6294 http://mcp.localhost:6293
API requests 5 34
403s 1403 POST /api/servers 0
data-status disconnected connected

The before failure is silent, which is the part the images make plain: no error, no toast, no red card — the deep-link server simply never appears, and the app looks entirely healthy. That is why this was worth a screenshot rather than a description.

@cliffhall

Copy link
Copy Markdown
Member Author

Replacing the screenshot with actual proof

A reviewer pointed out that the *.localhost screenshot shows http://127.0.0.1:6601/mcp on the deep-link card — which is the MCP server being connected to, not the Inspector's own origin. That is correct, and it is the second time I mislabelled evidence on this PR. The mcp.localhost part only ever existed in the address bar, which Playwright does not capture, so the image required you to take my word for the single fact it was supposed to establish. That is not proof.

Here is evidence that needs no trust — the same POST sent to each build with an explicit Origin header, no browser involved:

### BEFORE — v2/main (98911df)   server on 127.0.0.1:6294
  Origin: http://mcp.localhost:6294              -> HTTP 403
  Origin: http://tenant.app.localhost:6294       -> HTTP 403
  Origin: http://localhost:6294                  -> HTTP 200
  Origin: http://evil.com                        -> HTTP 403

### AFTER — this PR (924085d)    server on 127.0.0.1:6293
  Origin: http://mcp.localhost:6293              -> HTTP 200
  Origin: http://tenant.app.localhost:6293       -> HTTP 200
  Origin: http://localhost:6293                  -> HTTP 200
  Origin: http://evil.com                        -> HTTP 403

Reproduce it with:

curl -s -o /dev/null -w '%{http_code}\n' -X POST "http://127.0.0.1:$PORT/api/servers" \
  -H "Content-Type: application/json" \
  -H "x-mcp-remote-auth: Bearer $MCP_INSPECTOR_API_TOKEN" \
  -H "Origin: http://mcp.localhost:$PORT" \
  --data '{"id":"probe","config":{"type":"streamable-http","url":"http://127.0.0.1:6601/mcp"}}'

The two controls are what make it a result rather than an anecdote: http://localhost:PORT returns 200 on both builds, so a 200 means "reached the handler" and not "the guard is off; and http://evil.com stays 403 on both, so the guard is still doing its job. The only cells that move are the two *.localhost origins.

The screenshots stay in the PR body as corroboration — they show the app connected end to end and, in the before arm, that the failure is silent (no error, no toast, the deep-link server simply never appears). But the table above is the proof, and I should have led with it.

@cliffhall

Copy link
Copy Markdown
Member Author

Paused pending a scope question on #1944

Holding this for a reply from the reporter — #1944 (comment).

On a re-read the issue supports two different readings, and I built for one of them without checking:

  • "only accepts a select set of literal hosts when using http; all others require https" — that is the MCP server you connect to, and a precise description of the SDK's assertSecureTokenEndpoint.
  • "a proxy on a *.localhost domain that routes to the inspector" — that is the Inspector's own origin.

This PR implements the second. Testing the first: the Inspector does not reject a *.localhost server URL — nothing validates the host — and the failure is ENOTFOUND from Node's resolver, since Chrome maps *.localhost to loopback internally and Node does not. So with local DNS in place, plain HTTP to a *.localhost MCP server already worked before this PR; add OAuth and it is typescript-sdk#2591, which we cannot fix from here.

If the reporter needs the second reading, this PR is right. If they need the first, most of what is valuable here is #2280's error message plus documentation, and the origin work is answering a question nobody asked.

Two pieces of scope I generated, not the issue

Worth naming while this is paused, since both are candidates to split out and both are why this ran to 24 review rounds:

  • The root-dot normalization chain (HOST=localhost.). Nobody requested it. It originated in a review comment about an inconsistency in a helper I had just introduced, then cascaded through eight rounds and five call sites. Its user-facing value is that a spelling almost nobody types now works.
  • transport.strictPort — a new test-server config option with validation and 18 tests, which exists only to stabilise a fixture I built to produce a screenshot.

Neither is wrong and both are gated, but the actual feature is roughly 200 lines and this diff is 2,543 insertions across 38 files. I would rather say that plainly than let a reviewer discover it.

@cliffhall

Copy link
Copy Markdown
Member Author

#2280 extracted to #2288

The terminal-OAuth-notice half is now its own PR — #2288, branched from current v2/main, gate green, Copilot review requested.

It is correct on every answer to the #1944 scope question, so it should not wait on one. The reporter has since said "OAuth is involved in this case", which points at their MCP server being on a *.localhost host rather than the Inspector being hosted on one — and in that case #2288's error message is the only thing we can ship them, since the actual fix is typescript-sdk#2597.

The extraction is verifiably clean: 19 files, and every deleted line is one of my own refactors (the setter type widening, truncateUrlForDisplay, the port logic, two dependency arrays). Nothing from v2/main is reverted, and the #2280 code imports only InsecureTokenEndpointError and Mantine notifications — no reference to canonicalOriginHost, stripLoopbackRootDot, isLocalhostSubdomainHost or the origin flag.

This PR is parked

What remains here is the *.localhost origin work — allow-list, sandbox CSP, proxy referrer check, CLI handoff — plus the two pieces of scope I generated rather than the issue requesting: the root-dot normalization chain and strictPort (the latter travels with #2288, since it supports that fixture).

Pending the reporter confirming whether they also need the Inspector itself reachable on a *.localhost name. If they do not, my recommendation is to close this and file the origin work as its own enhancement issue carrying the research already done — RFC 6761, the Vite/Django/Rails precedent, and the four enforcement layers that have to agree. It is a real gap for anyone fronting the Inspector with a proxy; it just is not what #1944 turned out to be asking for.

I would rather re-file it honestly than merge 2,500 lines under a banner it does not belong to.

@cliffhall

Copy link
Copy Markdown
Member Author

Closing in favour of documentation.

The reporter confirmed both halves of their setup (#1944 comment) — the Inspector on inspector.localhost and an MCP server on my-mcp.localhost with OAuth. So this PR was not answering the wrong question. But it turns out to be answering an already-answered one.

ALLOWED_ORIGINS makes inspector.localhost work today. That is documented and it is one environment variable. Minus the #2280 work already split out to #2288, what remains here is 1,121 insertions across 18 files of origin-guard, CSP, sandbox-referrer and CLI-handoff code, to remove that env var. For security-sensitive code in four layers that all have to agree, that is a bad trade — the risk is not proportional to the convenience.

The reporter's actual blocker is typescript-sdk#2597, which we cannot fix from here, and #2288 ships the honest error message for it in the meantime.

What I am taking from this rather than discarding

The ALLOWED_ORIGINS recipe for *.localhost goes into the web README, including the trap that makes it easy to get wrong: the variable replaces the default list rather than merging, so listing only your proxy origin silently breaks browsing at localhost:PORT. Tracked separately.

For anyone who finds this later

The ecosystem argument for doing this properly is real — Vite, Django and Rails all default-allow .localhost in their own host allow-lists, and RFC 6761 §6.3 reserves the suffix to loopback, so it cannot be obtained by an attacker the way a rebound domain can. If this comes up again, the case is worth reopening as a considered enhancement with its own issue. The branch is not deleted, and the review history here is unusually thorough — 24 rounds, and the findings that matter are recorded inline:

  • the MCP Apps sandbox proxy (static/sandbox_proxy.html) carries its own hardcoded referrer allow-list, a third enforcement layer beyond the origin list and frame-ancestors, and it ships as static bytes so it cannot read configuration;
  • a trailing dot is a hostname's absolute form, not noise — normalizing it away changes which host is meant;
  • URL.origin is not always protocol + hostname: a nested-origin URL like blob: resolves through its inner origin and has an empty hostname.

Each of those cost a review round to learn and would cost the same again.

#1944 stays open tracking the upstream fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for *.localhost domains

2 participants