Skip to content

fix(mcp): Keep the env token's startup host when serving - #1801

Merged
BYK merged 5 commits into
mainfrom
fix/mcp-env-token-host
Nov 2, 2026
Merged

BYK merged 5 commits into
mainfrom
fix/mcp-env-token-host

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes three findings from Warden's run on #1731's final head c42da35d (check run, review comment). Warden finished after #1731 had auto-merged, so all three are on main. Review follow-ups add a fourth fix (the deprecated static token) and move the MCP host checks onto the CLI's shared helpers.

1. HIGH: the env token could be re-scoped to a .sentryclirc host

Verdict: real. serveMcp ran the rc-URL gate under a preferEnvToken(env) copy that kept the env token's startup host snapshot (inheritEnvTokenHost). It then served under a second copy, preferEnvToken(targetEnv), that didn't. env-token-host.ts keys snapshots per env object, so that copy re-captured its host from the SENTRY_URL the shim had already set. A claimless SENTRY_AUTH_TOKEN (forced over the stored login) was then scoped to the rc URL and passed the host check against the rc target.

Exploitability. assertRcUrlTrusted refuses a self-hosted rc URL unless the active token's host matches or hasStoredAccountForHost(rcUrl) holds. That second condition is the way in:

  • Setup: an MCP client sets a claimless SENTRY_AUTH_TOKEN (say, a SaaS user token). The user has also logged in to a self-hosted host X, and a repo's .sentryclirc sets url = X.
  • Result: the gate passes on the stored login, and the env token goes to X instead of the stored login's token.
  • Limits: X has to be a host the user logged in to. This is a cross-host leak to a server the user trusts with a different credential, not exfiltration to an arbitrary host.
  • Paths that don't get in:
    • The hasSentryTarget skip: with an explicit --host/--url, withTargetEnv replaces SENTRY_URL, so the rc URL isn't used.
    • The org-token skip: it uses the token's claim.
    • No stored login on the rc host: the gate refuses. Existing tests already covered that.

Fix: preferEnvToken gives its copy the startup snapshot, both for the gate and for serving. The refresh-on-401 closure reuses the same credentialEnv object, so it keeps the scope. Only two targets may scope the env token instead (mayRescopeEnvToken):

  • An explicit --host/--url, as before.
  • --insecure-http on the same host as the startup SENTRY_HOST/SENTRY_URL. Only the protocol changes there, so SENTRY_HOST=host:9000 --insecure-http still works.

--insecure-http on any other configured host (an rc URL or the persisted default) keeps the startup host. Warden flagged that path on this PR, and 547ac17f fixes it.

Tests:

  • keeps an env token with no host claim off an rc URL the user has logged in to covers both no flags and --insecure-http. It fails on the old code and passes here.
  • New: an explicit --host still scopes the env token when an rc URL points elsewhere.
  • New: --insecure-http on the startup SENTRY_HOST still serves over http.
  • New: a refresh assertion on the configured self-hosted case.

2. HIGH: fatal MCP errors exited 0 when stderr was already closed

Verdict: real. I reproduced it with a child process whose stderr the parent destroys. console.error raises EPIPE asynchronously, and stream-errors.ts calls process.exit(0) before the fatal path's exit(1) runs.

Fix: exitAfterError sets process.exitCode = 1 before writing. The EPIPE handler now calls process.exit(), so it exits with process.exitCode: 0 unless a failure was already recorded.

Test: a fatal error exits non-zero when its report hits a closed stderr wires the real stream handler to the fatal path. It fails on the old code and passes here.

3. MEDIUM: a replacement login was accepted during startup credential resolution

Verdict: real. accountIdentity hashes only the credential. If a login replaced the account row in place after startupPin() but before refreshToken read it, the row and the result matched, and the server started as the new user. Only the next refresh would have failed closed, through applyCredentialPin's user check.

Fix: isPinnedCredential now also requires the pinned user, through an isPinnedUser helper shared with applyCredentialPin.

Test: refuses another user's login over the startup account during startup fails on the old code and passes here.

Behavior changes

  • Claimless env tokens: an env token without a claim no longer follows a SENTRY_URL that only a .sentryclirc, or the persisted sentry cli defaults url, set after startup. Without an explicit target, sentry mcp with SENTRY_AUTH_TOKEN and a self-hosted persisted default now refuses with HostScopeError instead of sending the token there. From reading the code, this matches the boot-snapshot trust model the other commands use: preloadProjectContext captures the snapshot before the shim and the persisted default are applied.
  • CLI exit codes: the regular CLI's EPIPE handler (bin.ts) now also exits with an already-recorded non-zero process.exitCode instead of always exiting 0.

4. The deprecated --access-token / SENTRY_ACCESS_TOKEN got the same leak (review follow-up)

The first version of this PR left this case alone and asked about it. The answer was yes, so e384364e fixes it. deprecatedTokenEnv pinned the token's env copy to the MCP target. A claimless static token was then scoped to a .sentryclirc URL and sent there whenever a stored login on that host got past the rc-URL gate.

Fix: the deprecated token is now a plain alias for SENTRY_AUTH_TOKEN and takes exactly the same path:

  • the same forced env copy (clientTokenEnv)
  • the same rc-URL gate, checked against the token itself instead of the invocation's other credentials
  • the same startup-host scoping and mayRescopeEnvToken rule

So it keeps its startup SENTRY_HOST/SENTRY_URL (or SaaS), or its org-token claim, unless --host/--url targets another host. deprecatedTokenEnv and the org-token gate exemption are gone.

Tests:

  • keeps a static token with no host claim off an rc URL the user has logged in to replaces the old serves a static token for an rc URL the user has logged in to. It covers --access-token, --access-token --insecure-http, and SENTRY_ACCESS_TOKEN, and checks that an explicit --host still scopes the token. It fails on the old code.
  • New: serves a static token on the rc URL its startup SENTRY_URL names. The old gate wrongly refused this case because it checked the invocation's own credentials.
  • The rc test helper now snapshots the env-token host before the shim runs, which is the real boot order.

Reusing the CLI's auth logic instead of MCP copies

Most of the MCP-specific code existed because sentry mcp hands its token to mcp-core's own HTTP client. That client skips the CLI's fetch layer, which is where the CLI enforces host scoping. This change moves the remaining copies onto the CLI's own code:

  • Host guard: the fetch layer's per-request guard (the credential's host, plus the sntrys_ claim) moves into assertCredentialScope in token-host.ts. prepareHeaders and the MCP credential check both call it. Before, MCP had its own host-only check.
  • Startup-host snapshot: scopeEnvTokenToBootHost replaces inheritEnvTokenHost. It snapshots a derived env's token against the boot host with the same code captureEnvTokenHost runs at boot. The deprecated alias therefore needs no special case.
  • rc-URL gate: still the shared assertRcUrlTrusted. MCP sets skipRcUrlCheck and calls it itself only so that an explicit --host/--url can override the rc URL, and so the check runs against the token the server will actually use.

What stays MCP-specific is the account pin (startupPin/applyCredentialPin/isPinnedCredential). A one-shot CLI command reads its credential once. The MCP server is long-lived and refreshes after a 401, so another process's sentry auth switch or a login must not change who it acts as. No CLI path has that lifecycle. I looked at withStoredAccount/withEnvAccount. They pin a stored account to its own server, but they cannot detect that the row vanished or was replaced by another user mid-refresh, so the pin checks would still be needed on top of them.

Behavior change: like SENTRY_AUTH_TOKEN, a claimless deprecated token with no explicit target no longer follows a self-hosted persisted sentry cli defaults url. It now refuses with HostScopeError.

Checks

  • pnpm run lint and tsc --noEmit for packages/cli pass. The pre-commit generated-files check reports nothing to update.
  • vitest, full packages/cli unit suite on e384364e: 12,987 pass, and 12 fail the same way on the parent commit in this sandbox (completions, permission and readonly-fs, AuthError default messages, auto-login-host-guard wording), so they are unrelated to this change. The focused suites (mcp-start, test/lib/mcp/*, mcp-*, env-token-host, sentryclirc*, token-host*, sentry-client*, test/lib/security/*) pass, apart from the two pre-existing auto-login-host-guard failures.
  • The @sentry/mcp-server bundle builds, and test:bundle passes 15/15.

The branch was cut from e189f67b. e384364e merges cleanly with current main (b7c23cf4).

--

View Session in Sentry

serveMcp checked a .sentryclirc URL with the env token's startup host,
but served with a fresh copy of the environment whose host snapshot was
re-read from the post-shim SENTRY_URL. A stored login on the rc host got
past the rc-URL gate, and the forced SENTRY_AUTH_TOKEN was then scoped
to, and sent to, that host. preferEnvToken now gives a copy of the
current environment the startup snapshot, for the gate and for serving
(including the refresh-on-401 closure). An explicit --host/--url target
still scopes the token, as before.

Also fail closed when a login replaces the startup account's row in
place between the startup pin and the credential read: the credential
check now requires the pinned user, as applyCredentialPin already did.

Reported by Warden on #1731.
The fatal-error path wrote its report to stderr before exiting 1. With
the client's pipes closed, that write raised EPIPE, and the stream
handler exited 0 first. The fatal path now records exitCode = 1 before
writing, and the EPIPE handler exits with process.exitCode (0 unless a
failure was recorded).

Reported by Warden on #1731.
@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sentry-local Ready Ready Preview Nov 2, 2026 10:55pm UTC

Request Review

@github-actions github-actions Bot added the risk: medium PR risk score: medium label Nov 2, 2026
Comment thread packages/cli/src/lib/mcp.ts Resolved
withTargetEnv also copies the environment for --insecure-http alone,
and the copy then re-captured the env token's host from the post-shim
SENTRY_URL, so the .sentryclirc re-scoping came back with that flag.
The env token now keeps its startup host unless the target is an
explicit --host/--url, or --insecure-http names the same host as the
startup SENTRY_HOST/SENTRY_URL (only the protocol changes).

Reported by Warden on #1801.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>

@BYK BYK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I deliberately left one case unchanged. With the deprecated --access-token (or SENTRY_ACCESS_TOKEN), a claimless token is still sent to an rc host the user has a stored login on. deprecatedTokenEnv pins it to the configured target, and the existing test serves a static token for an rc URL the user has logged in to asserts that as intended. It has the same shape as finding 1, though. Should the deprecated token also keep the startup host and refuse there?

Yes. Also, all this auth/token management code should come from the existing CLI logic where we already secured these gaps? Why is all this logic replicated/created inside the mcp command?

…ed CLI guards

The deprecated --access-token / SENTRY_ACCESS_TOKEN pinned its env copy
to the MCP target, so a claimless token was scoped to a .sentryclirc
URL and sent there whenever a stored login on that host passed the
rc-URL gate. It is now a plain alias for SENTRY_AUTH_TOKEN: the same
env copy, the same rc gate (checked against the token itself), and
the same startup-host scoping, so it keeps its startup
SENTRY_HOST/SENTRY_URL (or SaaS) unless --host/--url targets another
host. The bespoke deprecatedTokenEnv and its org-token gate exemption
are gone.

To cut the MCP-only auth logic further:

- scopeEnvTokenToBootHost (replacing inheritEnvTokenHost) snapshots a
  derived env's token against the boot host with the same code as the
  boot-time capture, so the alias needs no snapshot of its own.
- The fetch layer's per-request host guard (credential host plus
  sntrys_ claim) moves to assertCredentialScope in token-host.ts, and
  the MCP credential check calls it instead of re-implementing a
  host-only check.

Requested by BYK on #1801.
@sentry-junior

sentry-junior Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed in e384364.

Deprecated token: yes. --access-token / SENTRY_ACCESS_TOKEN is now a plain alias for SENTRY_AUTH_TOKEN and runs through the same code: the same forced env copy, the same rc-URL gate (now checked against that token), and the same startup-host scoping. A claimless static token now refuses an rc host the user has a stored login on, and an explicit --host/--url still scopes it. deprecatedTokenEnv and its org-token gate exemption are gone.

Reusing the CLI logic: I moved the MCP copies onto the CLI's own helpers:

  • Host check: the fetch layer's per-request guard (credential host plus sntrys_ claim) is now assertCredentialScope in token-host.ts. prepareHeaders and the MCP credential check both call it. MCP needed its own check only because mcp-core's HTTP client skips the CLI fetch layer.
  • Startup-host snapshot: scopeEnvTokenToBootHost (replaces inheritEnvTokenHost) runs the same snapshot code as the boot-time captureEnvTokenHost.
  • rc-URL gate: still the shared assertRcUrlTrusted. MCP calls it itself only so an explicit --host/--url can override the rc URL.

What I left in mcp.ts is the account pin (startupPin / applyCredentialPin / isPinnedCredential). The server is long-lived and refreshes after a 401, so another process's auth switch or login must not change who it acts as. No one-shot command has that lifecycle. withStoredAccount pins the server, but it can't tell that the row vanished or was taken over by another user mid-refresh, so the pin checks would still sit on top of it. If you'd rather move the pin into db/auth.ts as a general "long-lived credential" helper, I can do that as a follow-up.

Conflict in packages/cli/src/lib/mcp.ts imports only: main (#1802)
added the CredentialRouter type import, and this branch had already
widened the db/accounts.js import.

#1802's createRouter gives each routed read's env copy the credential
env's env-token snapshot through inheritEnvTokenHost, which this branch
had replaced with scopeEnvTokenToBootHost. inheritEnvTokenHost is
restored for that caller with its main semantics; the client-token path
keeps scopeEnvTokenToBootHost.
@BYK
BYK enabled auto-merge (squash) November 2, 2026 22:56
@BYK
BYK merged commit 000b011 into main Nov 2, 2026
23 checks passed
@BYK
BYK deleted the fix/mcp-env-token-host branch November 2, 2026 23:03

This branch was successfully deployed

1 active deployment
Preview — af1a6021 Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant