feat: accept *.localhost origins, and stop reporting a terminal OAuth refusal as retryable - #2282
feat: accept *.localhost origins, and stop reporting a terminal OAuth refusal as retryable#2282cliffhall wants to merge 23 commits into
*.localhost origins, and stop reporting a terminal OAuth refusal as retryable#2282Conversation
… 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>
There was a problem hiding this comment.
🟡 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
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>
Review round 1 — all eight addressedEvery 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:
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: #7 was the sharpest catch — the copy contradicted this PR's own argument. The whole point is that
|
There was a problem hiding this comment.
🟡 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
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>
Review round 2 — both addressedBoth 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.
On #1 — round 1 only closed one layer. I fixed the predicate so On #2 — my round-1 reply asserted something false. I described
|
There was a problem hiding this comment.
🟡 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’sauth()refresh branch rethrowsInsecureTokenEndpointError, so clicking an existing banner after the endpoint becomes insecure bypasses this new terminal notice. Apply the classifier inonReauthenticateFromBanner’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
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>
Review round 4 — all four addressedRound 3 returned zero comments; this round returned four, which is a useful reminder that one clean round is not a verdict.
#2 is the one that mattered. #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
|
There was a problem hiding this comment.
🟡 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
… 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>
Review round 5 — addressedOne finding, and a good one. Reviewed against the merged head (
The critique of the test was also correct: 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
|
There was a problem hiding this comment.
🟡 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
serverIdinto this helper and use a functional update that only clears a banner whoseserverIdmatches.
- Files reviewed: 38/38 changed files
- Comments generated: 1
- Review effort level: Balanced
… 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>
Review round 20 — both addressed
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 The leak is one I had already been taught. Round 4 flagged exactly this on the The coverage gate caught something better than a threshold miss
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.
|
There was a problem hiding this comment.
🟡 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
parsedchanges nested-origin URLs that the existing guard accepts. For example,new URL("blob:https://example.com/id").originishttps://example.com, but this code storesblob://; because that leavesconfiguredOriginsnon-empty, the default is suppressed and every real browser origin is rejected. Normalize the already-derivedparsedOrigininstead, preserving the previousnew URL(o).origincontract 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
…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>
Review round 22 — both addressed
The 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 The second was arbitrary rather than conservative on my part. The feature rests on RFC 6761 treating
|
There was a problem hiding this comment.
🔵 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
Review loop closed — two consecutive clean roundsRounds 23 and 24 both returned zero inline comments, no What the review actually foundTwenty-four rounds. Fifteen carried at least one real finding. The distribution is the part worth knowing if you run this loop yourself:
Where the risk actually concentratedThe For the human reviewer
|
Added the missing screenshots for #1944A reviewer pointed out that the screenshots demonstrated nothing about The new pair browses the Inspector at 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 Driving a real connect gives a decisive result:
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. |
Replacing the screenshot with actual proofA reviewer pointed out that the Here is evidence that needs no trust — the same POST sent to each build with an explicit 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: 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. |
Paused pending a scope question on #1944Holding 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:
This PR implements the second. Testing the first: the Inspector does not reject a 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 issueWorth naming while this is paused, since both are candidates to split out and both are why this ran to 24 review rounds:
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. |
#2280 extracted to #2288The terminal-OAuth-notice half is now its own PR — #2288, branched from current 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 The extraction is verifiably clean: 19 files, and every deleted line is one of my own refactors (the setter type widening, This PR is parkedWhat remains here is the Pending the reporter confirming whether they also need the Inspector itself reachable on a I would rather re-file it honestly than merge 2,500 lines under a banner it does not belong to. |
|
Closing in favour of documentation. The reporter confirmed both halves of their setup (#1944 comment) — the Inspector on
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 discardingThe For anyone who finds this laterThe ecosystem argument for doing this properly is real — Vite, Django and Rails all default-allow
Each of those cost a review round to learn and would cost the same again. #1944 stays open tracking the upstream fix. |
Paused pending a scope question on #1944
Holding this for a reply from the reporter — #1944 (comment).
Closes #1944
Closes #2280
*.localhostsupport, 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.
http://*.localhostassertSecureTokenEndpoint)http://mcp.localhostALLOWED_ORIGINSis set--devhost checkallowedHostscovers.localhostB —
*.localhostorigins are accepted by defaultWhen the origin allow-list is the derived default and the bind host serves loopback,
/api/*now also accepts anyhttp(s)origin whose host ends in.localhost, at any port. The MCP Apps sandbox and app-originframe-ancestorsadmit 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.localhostis 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:
ALLOWED_ORIGINSturns 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.foo.localhostresolves to127.0.0.1and never reaches such a process, so admitting the origin would be a no-op that only made the allow-list harder to reason about.allowedOriginsstays a list of literal origins compared exactly. The widening is a separate, explicitly-namedallowLocalhostSubdomainOriginsflag 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 hazardALLOWED_ORIGINSalready rejects wildcards to avoid.A / #2280 — a terminal error stops pretending to be retryable
InsecureTokenEndpointErrorappeared 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 extendOAuthError, andauth()special-cases it to rethrow rather than start a fresh/authorizeredirect.It is now recognized (
core/auth/insecureTokenEndpoint.ts, mirroringissuerBinding.ts's brand-plus-nameclassifier) and surfaced as the configuration error it is — naming the endpoint and both ways out, with no action affordance. Claimed at the singleshowReAuthBannerfunnel 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
*.localhosttoken endpoint actually work has to land in the SDK — the assertion runs insideexecuteTokenRequest, 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 indocs/test-servers.md. ItsissuerUrlishttp://localhost.:8091— the root-anchored spelling oflocalhost, 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/hostsentry or dnsmasq.One caveat worth stating
Only the browser resolves
*.localhostfor 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*.localhosthost still needs a hosts entry or dnsmasq — the Inspector's backend is what dials it. This is called out inclients/web/README.md.Testing
npm run local:gatepasses. New coverage: the two host predicates including theapp.localhost.evil.comsuffix 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 —
*.localhostorigin supportThe proof is the origin table, not the screenshots. A screenshot cannot show this: the
*.localhostpart 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 explicitOrigin, no browser involved:Originsentv2/main)http://mcp.localhost:PORThttp://tenant.app.localhost:PORThttp://localhost:PORT— positive controlhttp://evil.com— negative controlThe controls are what make it a result:
localhostreturns 200 on both, so a 200 means "reached the handler" rather than "the guard is off";evil.comstays 403 on both, so the guard still works. Only the two*.localhostrows move.The screenshots below corroborate it end to end. Both browse the Inspector at
http://mcp.localhost:PORT(Chromium resolves*.localhostto loopback per RFC 6761 with no hosts entry) and auto-connect via deep link. Note the page load alone proves nothing — Chrome omitsOriginon 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/serversis 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.After — the
deep-linkcard is present and green Connected, with live protocol traffic in the sidebar. 34 API requests, 0 × 403,data-status="connected", atwindow.location.origin === "http://mcp.localhost:6293".#2280 — the terminal OAuth notice
Captured against the new
oauth-insecure-token-endpoint-http.jsonfixture, 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.
After — a non-dismissing notice naming the endpoint and both ways out, with no action affordance and no red card.