Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/operations/stdio-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,12 @@ or `SENTRY_ACCESS_TOKEN`. Both still work, as an alias: the server runs as if
and takes the same env-token path. It also
replaces any `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` already in the environment,
because the flag is the more explicit choice. `--access-token` wins over
`SENTRY_ACCESS_TOKEN`. Like a plain env token, the alias is sent to the
configured Sentry instance (an org token's own URL claim still has to match
it).
`SENTRY_ACCESS_TOKEN`. Like a plain env token, the alias is scoped to the
Sentry instance configured at startup (`SENTRY_HOST`/`SENTRY_URL`, or
`--host`/`--url`), else SaaS; an org token's own URL claim wins. A
`.sentryclirc` URL or the persisted default URL never scopes it, so the server
refuses to send it there (`HostScopeError`), even when you have a stored login
on that server.

Either one prints a single deprecation warning to stderr (stdout carries the
MCP protocol), without the token:
Expand Down
33 changes: 27 additions & 6 deletions packages/cli/src/lib/env-token-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin";
import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js";
import { getRawEnvToken } from "./db/auth.js";
import { getEnv } from "./env.js";
import { getEnv, withEnv } from "./env.js";
import { ConfigError } from "./errors.js";
import { parseSntrysClaim } from "./token-claims.js";

Expand Down Expand Up @@ -132,16 +132,21 @@ export function captureEnvTokenHost(): void {
env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim(),
env.SENTRY_HOST?.trim() ? "SENTRY_HOST" : "SENTRY_URL",
) ?? null;
snapshotState.byEnv.set(env, snapshotEnvToken(configuredHost));
}

/** Scope the current env token to its claim, else `configuredHost`, else SaaS. */
function snapshotEnvToken(configuredHost: string | null): HostSnapshot {
const token = getRawEnvToken();
// Claim first: for sntrys_ tokens, the embedded url is authoritative.
const claim = captureClaimHost(token);
snapshotState.byEnv.set(env, {
return {
configuredHost,
host: claim.host ?? configuredHost ?? DEFAULT_SENTRY_URL,
hostFromClaim: claim.host !== undefined,
...(claim.error ? { claimError: claim.error } : {}),
...(token ? { token } : {}),
});
};
}

/**
Expand Down Expand Up @@ -193,11 +198,27 @@ export function getTrustedEnvTokenHost(): string | undefined {
return snapshot.host;
}

/**
* Scope the env token in `derived`, a copy of the current environment, as if
* it had been set at startup: to its `sntrys_` claim, else the
* `SENTRY_HOST`/`SENTRY_URL` this environment had at boot, else SaaS. By now
* the copy's `SENTRY_HOST`/`SENTRY_URL` can come from a `.sentryclirc` or the
* persisted default, which must not scope the token. The copy may carry a
* different token than the boot environment did (`sentry mcp`'s deprecated
* `--access-token`), so the token is read from the copy.
*/
export function scopeEnvTokenToBootHost(derived: NodeJS.ProcessEnv): void {
const configuredHost = getBootConfiguredSentryUrl() ?? null;
snapshotState.byEnv.set(
derived,
withEnv(derived, () => snapshotEnvToken(configuredHost)),
);
}

/**
* Give `derived`, a copy of the current environment, the env-token host this
* environment snapshotted instead of one re-read from the copy. By now the
* copy's `SENTRY_HOST`/`SENTRY_URL` can come from a `.sentryclirc`, which must
* not scope the env token.
* environment snapshotted instead of one re-read from the copy, so the copy
* scopes its env token the same way.
*/
export function inheritEnvTokenHost(derived: NodeJS.ProcessEnv): void {
const env = getEnv();
Expand Down
180 changes: 105 additions & 75 deletions packages/cli/src/lib/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { normalizeAuthToken } from "./auth-header.js";
import { getConfiguredSentryUrl } from "./constants.js";
import { getAccountScope } from "./db/account-scope.js";
import { accountIdentity, getAccountRow } from "./db/accounts.js";
import type { CredentialRouter } from "@sentry/mcp-core/types";
import {
type AccountRow,
accountIdentity,
getAccountRow,
} from "./db/accounts.js";
import {
getCredentialContext,
getEffectiveAccountRow,
type RefreshTokenResult,
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
refreshToken,
withStoredAccount,
} from "./db/auth.js";
import { inheritEnvTokenHost } from "./env-token-host.js";
import {
getBootConfiguredSentryUrl,
inheritEnvTokenHost,
scopeEnvTokenToBootHost,
} from "./env-token-host.js";
import { getEnv, withEnv } from "./env.js";
import { AuthError, HostScopeError } from "./errors.js";
import { AuthError } from "./errors.js";
import {
deprecatedFlags,
type McpFlags,
Expand All @@ -25,8 +34,7 @@ import {
} from "./mcp/account-router.js";
import { finalizeMcpConfig } from "./mcp/config.js";
import { assertRcUrlTrusted, getRcInjectedTokenSource } from "./sentryclirc.js";
import { parseSntrysClaim } from "./token-claims.js";
import { getActiveTokenHost, isHostTrusted } from "./token-host.js";
import { assertCredentialScope } from "./token-host.js";

type McpTarget = {
sentryHost: string;
Expand Down Expand Up @@ -85,6 +93,18 @@ const ACCOUNT_CHANGED_MESSAGE =

const ENV_PIN: McpCredentialPin = { kind: "env" };

/**
* Whether `row` still belongs to the pinned user. A login can replace an
* account's credentials in place, so the account ID alone is not enough when
* the account recorded a user.
*/
function isPinnedUser(
row: AccountRow,
pin: Extract<McpCredentialPin, { kind: "stored" }>,
): boolean {
return !pin.userId || row.user_id === pin.userId;
}

/**
* Point this process's credential reads at the pinned credential, or fail
* closed when it is gone. The host check alone cannot tell two accounts on
Expand All @@ -98,9 +118,7 @@ function applyCredentialPin(pin: McpCredentialPin): void {
return;
}
const row = getAccountRow(pin.accountId);
// A login can replace an account's credentials in place, so also require
// the same user when the account recorded one.
if (!row?.token || (pin.userId && row.user_id !== pin.userId)) {
if (!(row?.token && isPinnedUser(row, pin))) {
throw new AuthError("expired", ACCOUNT_CHANGED_MESSAGE, {
skipAutoAuth: true,
});
Expand All @@ -123,7 +141,9 @@ function startupPin(): McpCredentialPin | undefined {
/**
* Whether `result` is the pinned credential. A stored pin must match the
* account row's current credential: if the row vanished mid-refresh, the
* CLI falls back to another account, and that token must not be used.
* CLI falls back to another account, and that token must not be used. The
* row must also still belong to the pinned user, in case a login replaced it
* between the pin and the credential read.
*/
function isPinnedCredential(
result: RefreshTokenResult,
Expand All @@ -137,7 +157,9 @@ function isPinnedCredential(
}
// SQLite yields null, not undefined, for a missing row.
const row = getAccountRow(pin.accountId);
return row ? accountIdentity(row) === result.identity : false;
return row
? isPinnedUser(row, pin) && accountIdentity(row) === result.identity
: false;
}

/**
Expand All @@ -159,15 +181,13 @@ export async function resolveCliMcpCredential(
applyCredentialPin(requested);
}
const result = await refreshToken({ force: options.force });
const tokenHost = getActiveTokenHost();

if (!(tokenHost && isHostTrusted(targetUrl, tokenHost))) {
throw new HostScopeError(
"Cannot start MCP server with the active CLI credentials",
targetUrl,
tokenHost,
);
}
// The same host guard the CLI applies to each of its own requests: the
// server sends this token to the target.
assertCredentialScope(
targetUrl,
getCredentialContext(),
"Cannot start MCP server with the active CLI credentials",
);

// At startup, an unusable stored credential falls back to the env token,
// the same as other CLI commands. Later, that fallback is a different
Expand Down Expand Up @@ -276,33 +296,6 @@ function warnDeprecatedAccessToken(source: DeprecatedAccessToken["source"]) {
);
}

/**
* The environment the deprecated token runs under: the same as
* `SENTRY_AUTH_TOKEN=<token> SENTRY_FORCE_ENV_TOKEN=1`, so it takes the CLI's
* env-token path and wins over any stored login. It also replaces an env
* token that is already set, since the flag (or `SENTRY_ACCESS_TOKEN`) is the
* more explicit choice for this server.
*
* The URL is pinned to the MCP target: `--host`/`--url` have always told the
* MCP server where to send this token, and the env-token snapshot for this
* copy then scopes the token to that server. An org auth token's own URL
* claim still wins, so it cannot be sent anywhere else.
*/
function deprecatedTokenEnv(
env: NodeJS.ProcessEnv,
token: string,
target: McpTarget,
): NodeJS.ProcessEnv {
const aliased: NodeJS.ProcessEnv = { ...env };
delete aliased.SENTRY_ACCESS_TOKEN;
delete aliased.SENTRY_TOKEN;
aliased.SENTRY_AUTH_TOKEN = token;
aliased.SENTRY_FORCE_ENV_TOKEN = "1";
delete aliased.SENTRY_HOST;
aliased.SENTRY_URL = `${target.sentryProtocol}://${target.sentryHost}`;
return aliased;
}

/**
* The environment with the deprecated `--host`/`--url`/`--insecure-http`
* target applied as `SENTRY_URL`, the setting they stand in for. The
Expand All @@ -326,20 +319,65 @@ function withTargetEnv(
}

/**
* The environment a `SENTRY_AUTH_TOKEN` (or `SENTRY_TOKEN`) runs under: forced
* over any stored login, as if `SENTRY_FORCE_ENV_TOKEN=1` were set. An MCP
* client passes the token in its server config to pick the account, so a
* stored CLI login must not silently replace it. Other commands keep
* preferring the stored login.
* Whether the env token may be scoped to the server's target instead of its
* startup host: for an explicit `--host`/`--url`, or for `--insecure-http` on
* the host the startup `SENTRY_HOST`/`SENTRY_URL` named, which only changes the
* protocol. `--insecure-http` on any other configured host (a `.sentryclirc`
* URL or the persisted default) must not.
*/
function mayRescopeEnvToken(flags: McpFlags, target: McpTarget): boolean {
if (hasSentryTarget(flags)) {
return true;
}
const bootUrl = getBootConfiguredSentryUrl();
return (
!!flags["insecure-http"] &&
bootUrl !== undefined &&
new URL(bootUrl).host === target.sentryHost
);
}

/**
* The environment the client's token runs under: `SENTRY_AUTH_TOKEN` (or
* `SENTRY_TOKEN`) forced over any stored login, as if `SENTRY_FORCE_ENV_TOKEN=1`
* were set. An MCP client passes the token in its server config to pick the
* account, so a stored CLI login must not silently replace it. Other commands
* keep preferring the stored login.
*
* The deprecated `--access-token` / `SENTRY_ACCESS_TOKEN` is an alias for it:
* it replaces any env token, as the more explicit choice for this server, and
* then takes the same path, with the same host scoping.
*
* A token the `.sentryclirc` shim copied in is not forced: it comes from the
* repository, not the client, so it must not replace the user's login.
*
* With `keepStartupHost`, the token is scoped as if set at startup (see
* `scopeEnvTokenToBootHost`): by now `SENTRY_URL` can come from a
* `.sentryclirc` or the persisted default, which must not scope it. See
* {@link mayRescopeEnvToken} for when the server's target may scope it.
*/
function preferEnvToken(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return (env.SENTRY_AUTH_TOKEN?.trim() || env.SENTRY_TOKEN?.trim()) &&
!getRcInjectedTokenSource()
? { ...env, SENTRY_FORCE_ENV_TOKEN: "1" }
: env;
function clientTokenEnv(
env: NodeJS.ProcessEnv,
deprecatedToken: string | undefined,
keepStartupHost: boolean,
): NodeJS.ProcessEnv {
if (
!deprecatedToken &&
(!(env.SENTRY_AUTH_TOKEN?.trim() || env.SENTRY_TOKEN?.trim()) ||
getRcInjectedTokenSource())
) {
return env;
}
const forced: NodeJS.ProcessEnv = { ...env, SENTRY_FORCE_ENV_TOKEN: "1" };
if (deprecatedToken) {
delete forced.SENTRY_ACCESS_TOKEN;
delete forced.SENTRY_TOKEN;
forced.SENTRY_AUTH_TOKEN = deprecatedToken;
}
if (keepStartupHost) {
scopeEnvTokenToBootHost(forced);
}
return forced;
}

/**
Expand Down Expand Up @@ -382,27 +420,19 @@ export async function serveMcp(
// source: without this gate, a token with no host claim would be sent to
// whatever server the file names. Apply the same rc-URL check as every
// other CLI command, unless an explicit `--host`/`--url` flag overrides the
// target, or the deprecated token is an org token, whose host claim the
// credential check below matches against the target. Checked against the
// invocation's own credentials, before the deprecated token replaces them:
// an env token from the client wins over a stored login, as it does for
// serving, but keeps the host snapshotted at startup, so the rc URL cannot
// scope it.
if (
!(
hasSentryTarget(flags) ||
(deprecatedToken && parseSntrysClaim(deprecatedToken))
)
) {
const checkEnv = preferEnvToken(env);
inheritEnvTokenHost(checkEnv);
await withEnv(checkEnv, () => assertRcUrlTrusted(cwd));
// target. Checked against the credential this server will use, scoped to
// its startup host, so the rc URL cannot scope it.
if (!hasSentryTarget(flags)) {
await withEnv(clientTokenEnv(env, deprecatedToken, true), () =>
assertRcUrlTrusted(cwd),
);
}

const targetEnv = withTargetEnv(env, flags, config);
const credentialEnv = deprecatedToken
? deprecatedTokenEnv(targetEnv, deprecatedToken, config)
: preferEnvToken(targetEnv);
const credentialEnv = clientTokenEnv(
withTargetEnv(env, flags, config),
deprecatedToken,
!mayRescopeEnvToken(flags, config),
);

// Loaded only now: mcp-core's server, the agent providers, and the MCP
// telemetry are not needed for --help or setup errors.
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/lib/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ export function installProcessHandlers(): (
process.exit(1);
}
exiting = true;
// Record the failure first: writing to a closed stderr below raises EPIPE,
// whose stream handler exits with the recorded code (see stream-errors.ts).
process.exitCode = 1;

// If the client closed every pipe, console.error can throw EPIPE inside
// this handler and recurse forever (getsentry/sentry-mcp#1274).
Expand Down
Loading
Loading