diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index f73eca9b6..21665597c 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -92,6 +92,7 @@ toolkit/ │ │ │ ├── help.ts # Help command │ │ │ ├── info.ts # Print configuration and verify authentication │ │ │ ├── init.ts # Initialize Sentry in your project (experimental) +│ │ │ ├── mcp.ts # Start a local Sentry MCP server │ │ │ ├── schema.ts # Browse the Sentry API schema │ │ │ └── wasm-split.ts# Add build ids to WebAssembly modules and split out debug data │ │ ├── lib/ # Shared utilities diff --git a/apps/cli-docs/src/fragments/commands/mcp.md b/apps/cli-docs/src/fragments/commands/mcp.md new file mode 100644 index 000000000..54482a642 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/mcp.md @@ -0,0 +1,30 @@ + +`sentry mcp` starts the local stdio Sentry MCP server using the active Sentry CLI session. Authenticate once with `sentry auth login`; no separate MCP login or token cache is required. + +## Configure an MCP client + +Point your MCP client at the installed Sentry CLI: + +```json +{ + "mcpServers": { + "sentry": { + "command": "sentry", + "args": ["mcp"] + } + } +} +``` + +For a self-hosted instance, first authenticate the CLI against that host. You can also override the target for this MCP server invocation: + +```json +{ + "mcpServers": { + "sentry": { + "command": "sentry", + "args": ["mcp", "--host=sentry.example.com"] + } + } +} +``` diff --git a/packages/cli/package.json b/packages/cli/package.json index e3556c168..e00308e16 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,16 +58,16 @@ "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", "cli": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", - "build": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", - "build:all": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", - "bundle": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", - "typecheck": "pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit", + "build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", + "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", + "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", + "typecheck": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit", "lint": "pnpm -w exec oxlint --report-unused-disable-directives packages/cli && pnpm -w exec oxfmt --check packages/cli", "lint:fix": "pnpm -w exec oxlint --fix packages/cli && pnpm -w exec oxfmt --write packages/cli", "test": "pnpm run test:unit", "test:unit": "pnpm run generate:docs && pnpm run generate:sdk && vitest run test/lib test/commands test/types test/script --coverage", "test:changed": "pnpm run generate:docs && pnpm run generate:sdk && vitest run --changed", - "test:e2e": "pnpm run generate:docs && pnpm run generate:sdk && vitest run test/e2e", + "test:e2e": "pnpm tsx script/prepare-e2e-bundle.ts && vitest run test/e2e", "test:init-eval": "vitest run test/init-eval --testTimeout 600000", "generate:parser": "pnpm tsx script/generate-parser.ts", "generate:sdk": "pnpm tsx script/generate-sdk.ts", @@ -92,6 +92,7 @@ "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs" }, "devDependencies": { + "@sentry/mcp-server": "workspace:*", "@anthropic-ai/sdk": "^0.39.0", "@clack/prompts": "0.11.0", "@hono/node-server": "^2.0.10", diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index a7b5b8eff..5dcc7ec2c 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -650,6 +650,14 @@ View Sentry logs → Full flags and examples: `references/log.md` +### Mcp + +Start a local Sentry MCP server + +- `sentry mcp` — Start a local Sentry MCP server + +→ Full flags and examples: `references/mcp.md` + ### Monitor Work with Sentry cron monitors diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md new file mode 100644 index 000000000..ceaa7d886 --- /dev/null +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md @@ -0,0 +1,18 @@ +--- +name: sentry-cli-mcp +version: 0.48.0-dev.0 +description: Start a local Sentry MCP server +requires: + bins: ["sentry"] + auth: true +--- + +# Mcp Commands + +Start a local Sentry MCP server + +### `sentry mcp` + +Start a local Sentry MCP server + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/prepare-e2e-bundle.ts b/packages/cli/script/prepare-e2e-bundle.ts new file mode 100644 index 000000000..3a79556fc --- /dev/null +++ b/packages/cli/script/prepare-e2e-bundle.ts @@ -0,0 +1,19 @@ +import { spawn } from "node:child_process"; + +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const child = spawn(pnpm, ["run", "bundle"], { + env: { + ...process.env, + SENTRY_CLIENT_ID: process.env.SENTRY_CLIENT_ID ?? "test-client-id", + }, + stdio: "inherit", +}); + +const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); +}); + +if (exitCode !== 0) { + process.exitCode = exitCode; +} diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index f0eff58a1..765371330 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -34,6 +34,7 @@ import { listCommand as issueListCommand } from "./commands/issue/list.js"; import { localRoute } from "./commands/local/index.js"; import { logRoute } from "./commands/log/index.js"; import { listCommand as logListCommand } from "./commands/log/list.js"; +import { mcpCommand } from "./commands/mcp.js"; import { monitorRoute } from "./commands/monitor/index.js"; import { listCommand as monitorListCommand } from "./commands/monitor/list.js"; import { orgRoute } from "./commands/org/index.js"; @@ -142,6 +143,7 @@ export const routes = buildRouteMap({ explore: exploreCommand, feedback: feedbackRoute, log: logRoute, + mcp: mcpCommand, monitor: monitorRoute, snapshots: snapshotsRoute, sourcemap: sourcemapRoute, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b598bddbf..6bc08621e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,6 +13,7 @@ import { redactCredentialText } from "./lib/credential-redaction.js"; import { getEnv } from "./lib/env.js"; import { CliError, formatError } from "./lib/errors.js"; +import { buildTopLevelFlags } from "./lib/global-flags.js"; import { initTimezone } from "./lib/timezone.js"; /** @@ -228,6 +229,83 @@ type ErrorMiddleware = ( retryArgs: string[], ) => Promise; +/** + * Return MCP's arguments when it is the command after leading global flags. + * + * MCP owns stdout for JSON-RPC, so CLI-level output flags are deliberately + * ignored before it starts. Flags after `mcp` belong to the MCP server. + */ +function skipLeadingGlobalFlag( + cliArgs: readonly string[], + index: number, +): number | undefined { + const { booleanFlags, valueFlags } = buildTopLevelFlags(); + const token = cliArgs[index] ?? ""; + const flag = token.split("=", 1)[0] ?? token; + + if (booleanFlags.has(flag)) { + return index + 1; + } + if (!valueFlags.has(flag)) { + return; + } + return token.includes("=") || cliArgs[index + 1] === undefined + ? index + 1 + : index + 2; +} + +export function getMcpArgs(cliArgs: readonly string[]): string[] | undefined { + for (let index = 0; index < cliArgs.length;) { + const token = cliArgs[index] ?? ""; + if (token === "--") { + return; + } + if (!token.startsWith("-")) { + return token === "mcp" ? cliArgs.slice(index + 1) : undefined; + } + + const nextIndex = skipLeadingGlobalFlag(cliArgs, index); + if (nextIndex === undefined) { + return; + } + index = nextIndex; + } + + return; +} + +/** Run MCP and return whether the current invocation was handled by it. */ +export async function runMcpCommand(cliArgs: string[]): Promise { + const mcpArgs = getMcpArgs(cliArgs); + if (!mcpArgs) { + return false; + } + + const [{ startMcpServer }, { getExitCode }] = await Promise.all([ + import("./lib/mcp.js"), + import("./lib/errors.js"), + ]); + + try { + await startMcpServer(mcpArgs); + } catch (mcpError) { + process.stderr.write(`${formatError(mcpError)}\n`); + process.exitCode = getExitCode(mcpError); + // MCP setup errors are terminal, unlike a running stdio server. Clean up + // network resources only on this error path so successful servers retain + // their dispatcher and are not force-exited on macOS. + const [{ scheduleForceExit }, { closeGlobalDispatcher }] = + await Promise.all([ + import("./lib/force-exit.js"), + import("./lib/close-dispatcher.js"), + ]); + scheduleForceExit(); + await closeGlobalDispatcher(); + } + + return true; +} + /** * Full CLI execution with telemetry, middleware, and error recovery. * @@ -235,6 +313,10 @@ type ErrorMiddleware = ( * `__complete` fast-path can skip them entirely. */ export async function runCli(cliArgs: string[]): Promise { + if (await runMcpCommand(cliArgs)) { + return; + } + const { isatty } = await import("node:tty"); const { ExitCode, run } = await import("@stricli/core"); const { app } = await import("./app.js"); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 000000000..1d766e480 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,22 @@ +import type { SentryContext } from "../context.js"; +import { buildCommand } from "../lib/command.js"; +import { CommandOutput } from "../lib/formatters/output.js"; + +/** Documentation route for the stdio handoff in {@link runCli}. */ +export const mcpCommand = buildCommand({ + auth: false, + docs: { + brief: "Start a local Sentry MCP server", + fullDescription: + "Start the local stdio MCP server using the current Sentry CLI session. " + + "Configure an MCP client with `sentry mcp`; authenticate first with `sentry auth login`.", + }, + output: { human: (message: string) => message }, + parameters: {}, + // biome-ignore lint/suspicious/useAwait: async generator required by buildCommand + async *func(this: SentryContext) { + yield new CommandOutput( + "The local MCP server is started by running `sentry mcp` from an MCP client configuration.", + ); + }, +}); diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts new file mode 100644 index 000000000..9243c6d48 --- /dev/null +++ b/packages/cli/src/lib/mcp.ts @@ -0,0 +1,165 @@ +import { normalizeAuthToken } from "./auth-header.js"; +import { getConfiguredSentryUrl } from "./constants.js"; +import { refreshToken } from "./db/auth.js"; +import { getEnv } from "./env.js"; +import { HostScopeError, ValidationError } from "./errors.js"; +import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; + +type McpServerConfig = { + sentryHost: string; + sentryProtocol: "http" | "https"; +}; + +function hasSentryTargetArg(args: readonly string[]): boolean { + return args.some( + (arg) => + arg === "--host" || + arg.startsWith("--host=") || + arg === "--url" || + arg.startsWith("--url="), + ); +} + +/** + * Translate the CLI's URL setting into MCP's host/protocol flags. + * + * The MCP parser intentionally rejects insecure SENTRY_URL values, while the + * CLI uses them for self-hosted defaults. Passing explicit flags preserves the + * selected CLI host without letting SENTRY_URL override --insecure-http. + */ +export function prepareMcpServerArgs( + args: readonly string[], + sentryUrl = getConfiguredSentryUrl(), +): string[] { + if (!sentryUrl || hasSentryTargetArg(args)) { + return [...args]; + } + + // oxlint-disable-next-line sentry-cli/no-silent-catch -- invalid URLs are passed through to the MCP parser for its normal validation error. + try { + const url = new URL(sentryUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return [...args, `--url=${sentryUrl}`]; + } + return [ + ...args, + `--host=${url.host}`, + ...(url.protocol === "http:" ? ["--insecure-http"] : []), + ]; + } catch { + return [...args, `--url=${sentryUrl}`]; + } +} + +/** + * Resolve a credential for the local MCP server from the CLI's authenticated + * session, preserving the CLI's host-scoping protections. + * + * Pass `force` to bypass the refresh threshold — used after an upstream 401 so + * a long-running server picks up a fresh token without being restarted. + */ +export async function resolveCliMcpAccessToken( + config: McpServerConfig, + options: { force?: boolean } = {}, +): Promise { + const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`; + const { token } = 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, + ); + } + + // Env tokens and stored rows bypass the refresh path's normalization, so a + // credential with pasted newlines or padding could reach the MCP server. Trim + // and validate here before it becomes an Authorization header. + return normalizeAuthToken(token); +} + +/** + * Resolve the MCP credential, falling back to the interactive login flow when + * the CLI has no usable session and the terminal is interactive. + * + * Mirrors how the rest of the CLI recovers from `not_authenticated`/`expired` + * errors: in a TTY it launches the OAuth device flow (browser), then retries. + * In a non-interactive context (e.g. an IDE launching `sentry mcp` over pipes) + * it rethrows the original {@link AuthError} so the client sees a clear + * "run `sentry auth login`" message instead of a hung browser prompt. + */ +async function resolveMcpAccessTokenWithLogin( + config: McpServerConfig, +): Promise { + try { + return await resolveCliMcpAccessToken(config); + } catch (error) { + const { isatty } = await import("node:tty"); + const { shouldAutoAuth, assertAutoLoginHostTrusted } = + await import("./auto-auth.js"); + const isInteractive = () => isatty(0); + if (!shouldAutoAuth(error, isInteractive)) { + throw error; + } + + // Never start an OAuth device flow against an unconfirmed self-hosted host. + assertAutoLoginHostTrusted(); + + process.stderr.write( + error.reason === "expired" + ? "Authentication expired. Starting login flow...\n\n" + : "Authentication required. Starting login flow...\n\n", + ); + + const { runInteractiveLogin } = await import("./interactive-login.js"); + const loginResult = await runInteractiveLogin(); + if (!loginResult) { + throw error; + } + + return resolveCliMcpAccessToken(config); + } +} + +/** Start the local stdio server without introducing a second auth flow. */ +export async function startMcpServer(args: string[]): Promise { + if (args[0] === "auth") { + throw new ValidationError( + "Use `sentry auth` to manage credentials for `sentry mcp`.", + ); + } + + const { runMcpServer } = await import("@sentry/mcp-server"); + const { + SENTRY_HOST: _sentryHost, + SENTRY_URL: _sentryUrl, + ...mcpEnv + } = getEnv(); + + // Remember the resolved target so the 401 handler can refresh against the + // same host without re-parsing args. + let resolvedConfig: McpServerConfig | undefined; + + await runMcpServer(prepareMcpServerArgs(args), { + environment: mcpEnv, + packageName: "sentry mcp", + resolveAccessToken: (config) => { + resolvedConfig = config; + return resolveMcpAccessTokenWithLogin(config); + }, + onUpstreamUnauthorized: async (setAccessToken) => { + if (!resolvedConfig) { + return; + } + // Force a refresh past the usual threshold: the upstream already rejected + // the current token, so mirror `--follow`'s refresh-on-401 behavior. + const token = await resolveCliMcpAccessToken(resolvedConfig, { + force: true, + }); + setAccessToken(token); + }, + throwOnError: true, + }); +} diff --git a/packages/cli/test/e2e/bundle-setup.ts b/packages/cli/test/e2e/bundle-setup.ts index ac18e291f..04e3242f7 100644 --- a/packages/cli/test/e2e/bundle-setup.ts +++ b/packages/cli/test/e2e/bundle-setup.ts @@ -80,7 +80,9 @@ async function runBundleBuild(): Promise { } async function waitForBundle(): Promise { - const deadline = Date.now() + 55_000; + // Building the bundled CLI also builds its MCP runtime dependencies. On + // cold CI runners that can take longer than the old 55-second allowance. + const deadline = Date.now() + 115_000; while (Date.now() < deadline) { if (existsSync(BUNDLE_INDEX_PATH) && !existsSync(LOCK_DIR)) { return; diff --git a/packages/cli/test/e2e/bundle.test.ts b/packages/cli/test/e2e/bundle.test.ts index 3fb866a42..2ff76095b 100644 --- a/packages/cli/test/e2e/bundle.test.ts +++ b/packages/cli/test/e2e/bundle.test.ts @@ -51,7 +51,7 @@ const INK_APP_PATH = join(ROOT_DIR, "dist/ink-app.js"); describe("npm bundle", () => { beforeAll(async () => { await ensureBundleBuilt(); - }, 60_000); // Bundle can take a while + }, 120_000); // Cold CI builds include the MCP runtime dependencies test("bundle file exists", () => { expect(existsSync(BUNDLE_BIN_PATH)).toBe(true); diff --git a/packages/cli/test/e2e/library.test.ts b/packages/cli/test/e2e/library.test.ts index 6d9d09a10..c2a6f5392 100644 --- a/packages/cli/test/e2e/library.test.ts +++ b/packages/cli/test/e2e/library.test.ts @@ -92,7 +92,7 @@ describe("library mode (bundled)", () => { beforeAll(async () => { await ensureBundleBuilt(); - }, 60_000); + }, 120_000); // Cold CI builds include the MCP runtime dependencies // --- Bundle structure --- diff --git a/packages/cli/test/lib/mcp-auth.test.ts b/packages/cli/test/lib/mcp-auth.test.ts new file mode 100644 index 000000000..e0388a2d4 --- /dev/null +++ b/packages/cli/test/lib/mcp-auth.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { clearAuth, setAuthToken } from "../../src/lib/db/auth.js"; +import { + AuthError, + HostScopeError, + MalformedAuthTokenError, +} from "../../src/lib/errors.js"; +import { + prepareMcpServerArgs, + resolveCliMcpAccessToken, + startMcpServer, +} from "../../src/lib/mcp.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +useTestConfigDir("mcp-auth-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +beforeEach(async () => { + await resetHostScopingState(); +}); + +afterEach(async () => { + await resetHostScopingState(); +}); + +describe("resolveCliMcpAccessToken", () => { + test("returns the active CLI session token for its scoped host", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.io", + }); + + await expect( + resolveCliMcpAccessToken({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }), + ).resolves.toBe("cli-session-token"); + }); + + test("refuses to use a CLI token for another Sentry host", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.example.com", + }); + + await expect( + resolveCliMcpAccessToken({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }), + ).rejects.toBeInstanceOf(HostScopeError); + }); + + test("directs an unauthenticated user to the CLI login flow", async () => { + await clearAuth(); + + await expect( + resolveCliMcpAccessToken({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }), + ).rejects.toThrow(new AuthError("not_authenticated")); + }); + + test("normalizes a malformed env token instead of forwarding it", async () => { + await clearAuth(); + // Env tokens skip the stored-row normalization, so a pasted newline would + // otherwise reach the MCP server as a broken Authorization header. + process.env.SENTRY_AUTH_TOKEN = "env-token\n"; + process.env.SENTRY_HOST = "https://sentry.io"; + + await expect( + resolveCliMcpAccessToken({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }), + ).resolves.toBe("env-token"); + }); + + test("rejects an env token with embedded invalid characters", async () => { + await clearAuth(); + process.env.SENTRY_AUTH_TOKEN = "env token with space"; + process.env.SENTRY_HOST = "https://sentry.io"; + + await expect( + resolveCliMcpAccessToken({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }), + ).rejects.toBeInstanceOf(MalformedAuthTokenError); + }); + + test("does not expose a second MCP authentication flow", async () => { + await expect(startMcpServer(["auth", "login"])).rejects.toThrow( + "Use `sentry auth` to manage credentials for `sentry mcp`.", + ); + }); +}); + +describe("prepareMcpServerArgs", () => { + test("uses SENTRY_HOST before SENTRY_URL for self-hosted instances", () => { + process.env.SENTRY_HOST = "http://sentry.internal:9000"; + process.env.SENTRY_URL = "https://sentry.example.com"; + + expect(prepareMcpServerArgs([])).toEqual([ + "--host=sentry.internal:9000", + "--insecure-http", + ]); + }); + + test("translates an insecure CLI URL into MCP host flags", () => { + expect(prepareMcpServerArgs([], "http://sentry.internal:9000")).toEqual([ + "--host=sentry.internal:9000", + "--insecure-http", + ]); + }); + + test("translates a secure SENTRY_HOST origin into a host flag", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + + // A documented `SENTRY_HOST=https://…` export must not reach the MCP + // parser raw (it rejects origins); it becomes a plain --host instead. + expect(prepareMcpServerArgs([])).toEqual(["--host=sentry.example.com"]); + }); + + test("preserves an explicit MCP target over the CLI URL", () => { + expect( + prepareMcpServerArgs( + ["--host=sentry.example.com"], + "http://localhost:9000", + ), + ).toEqual(["--host=sentry.example.com"]); + }); + + test("keeps an explicit --host --insecure-http over the CLI URL", () => { + // The CLI http URL must not sneak in an extra --host that MCP would + // prefer over the user's explicit insecure target. + expect( + prepareMcpServerArgs( + ["--host=localhost:9000", "--insecure-http"], + "http://sentry.example.com", + ), + ).toEqual(["--host=localhost:9000", "--insecure-http"]); + }); +}); diff --git a/packages/cli/test/lib/mcp-command.test.ts b/packages/cli/test/lib/mcp-command.test.ts new file mode 100644 index 000000000..dbfdb6077 --- /dev/null +++ b/packages/cli/test/lib/mcp-command.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "vitest"; +import { routes } from "../../src/app.js"; +import { getMcpArgs } from "../../src/cli.js"; + +test("exposes the local MCP server through the sentry CLI", () => { + expect( + routes.getAllEntries().some((entry) => entry.name.original === "mcp"), + ).toBe(true); +}); + +test("recognizes MCP after leading global flags", () => { + expect(getMcpArgs(["--verbose", "mcp", "--host=sentry.example.com"])).toEqual( + ["--host=sentry.example.com"], + ); + expect(getMcpArgs(["--org", "acme", "mcp"])).toEqual([]); + expect(getMcpArgs(["issue", "mcp"])).toBeUndefined(); +}); diff --git a/packages/cli/test/lib/mcp-run.test.ts b/packages/cli/test/lib/mcp-run.test.ts new file mode 100644 index 000000000..aefe31ff8 --- /dev/null +++ b/packages/cli/test/lib/mcp-run.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const startMcpServer = vi.fn(); +const scheduleForceExit = vi.fn(); +const closeGlobalDispatcher = vi.fn(() => Promise.resolve()); + +vi.mock("../../src/lib/mcp.js", () => ({ + startMcpServer: (...args: unknown[]) => startMcpServer(...args), +})); +vi.mock("../../src/lib/force-exit.js", () => ({ + scheduleForceExit: (...args: unknown[]) => scheduleForceExit(...args), +})); +vi.mock("../../src/lib/close-dispatcher.js", () => ({ + closeGlobalDispatcher: (...args: unknown[]) => closeGlobalDispatcher(...args), +})); + +const originalExitCode = process.exitCode; + +beforeEach(() => { + startMcpServer.mockReset(); + scheduleForceExit.mockReset(); + closeGlobalDispatcher.mockReset(); + closeGlobalDispatcher.mockImplementation(() => Promise.resolve()); +}); + +afterEach(() => { + process.exitCode = originalExitCode; +}); + +describe("runMcpCommand", () => { + test("does not handle non-mcp invocations", async () => { + const { runMcpCommand } = await import("../../src/cli.js"); + + await expect(runMcpCommand(["issue", "list"])).resolves.toBe(false); + expect(startMcpServer).not.toHaveBeenCalled(); + }); + + test("keeps a running server alive on success", async () => { + startMcpServer.mockResolvedValue(undefined); + const { runMcpCommand } = await import("../../src/cli.js"); + + await expect(runMcpCommand(["mcp"])).resolves.toBe(true); + + expect(startMcpServer).toHaveBeenCalledWith([]); + // A live stdio server must not be force-exited or have its dispatcher + // destroyed — those would kill the server right after it starts. + expect(scheduleForceExit).not.toHaveBeenCalled(); + expect(closeGlobalDispatcher).not.toHaveBeenCalled(); + }); + + test("cleans up network resources when setup fails", async () => { + startMcpServer.mockRejectedValue(new Error("boom")); + const { runMcpCommand } = await import("../../src/cli.js"); + + await expect(runMcpCommand(["mcp"])).resolves.toBe(true); + + // A terminal setup failure never reaches the main runCli finally, so the + // undici dispatcher and force-exit backstop must be handled here. + expect(scheduleForceExit).toHaveBeenCalledTimes(1); + expect(closeGlobalDispatcher).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts new file mode 100644 index 000000000..79af97885 --- /dev/null +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +type CapturedOptions = { + resolveAccessToken?: (config: unknown) => Promise; + onUpstreamUnauthorized?: ( + setAccessToken: (token: string) => void, + ) => void | Promise; +}; + +const captured: { options?: CapturedOptions } = {}; + +vi.mock("@sentry/mcp-server", () => ({ + runMcpServer: vi.fn(async (_args: string[], options: CapturedOptions) => { + captured.options = options; + }), +})); + +useTestConfigDir("mcp-start-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +beforeEach(async () => { + captured.options = undefined; + await resetHostScopingState(); +}); + +afterEach(async () => { + await resetHostScopingState(); +}); + +describe("startMcpServer credential wiring", () => { + test("resolves the CLI session token for the MCP server", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.io", + }); + + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await startMcpServer([]); + + const resolve = captured.options?.resolveAccessToken; + expect(resolve).toBeDefined(); + await expect( + resolve?.({ sentryHost: "sentry.io", sentryProtocol: "https" }), + ).resolves.toBe("cli-session-token"); + }); + + test("refreshes and writes back a new token on upstream 401", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.io", + }); + + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await startMcpServer([]); + + // The 401 handler needs the resolved target; prime it via resolveAccessToken. + await captured.options?.resolveAccessToken?.({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }); + + // Simulate a refreshed credential landing in the store before the handler runs. + setAuthToken("rotated-token", undefined, undefined, { + host: "https://sentry.io", + }); + + let written: string | undefined; + await captured.options?.onUpstreamUnauthorized?.((token) => { + written = token; + }); + + expect(written).toBe("rotated-token"); + }); + + test("ignores an upstream 401 before any token has been resolved", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.io", + }); + + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await startMcpServer([]); + + let written: string | undefined; + await captured.options?.onUpstreamUnauthorized?.((token) => { + written = token; + }); + + expect(written).toBeUndefined(); + }); +}); diff --git a/packages/mcp-server/src/cli/usage.test.ts b/packages/mcp-server/src/cli/usage.test.ts new file mode 100644 index 000000000..77a50e784 --- /dev/null +++ b/packages/mcp-server/src/cli/usage.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { runMcpServer } from "../index"; +import { buildUsage } from "./usage"; + +describe("buildUsage", () => { + it("describes host authentication without standalone login commands", () => { + const usage = buildUsage("sentry mcp", ["inspect"], { + usesHostAuthentication: true, + }); + + expect(usage).toContain("Uses the active Sentry CLI session"); + expect(usage).toContain("sentry auth login"); + expect(usage).not.toContain("device code flow"); + expect(usage).not.toContain("--access-token"); + expect(usage).not.toContain("auth [login|logout|status]"); + }); +}); + +describe("runMcpServer", () => { + it("returns host application setup errors without exiting", async () => { + await expect( + runMcpServer([], { + throwOnError: true, + resolveAccessToken: async () => { + throw new Error("No active CLI session"); + }, + }), + ).rejects.toThrow("No active CLI session"); + }); + + it("does not allow standalone auth commands with host authentication", async () => { + await expect( + runMcpServer(["auth", "login"], { + throwOnError: true, + resolveAccessToken: async () => "token", + }), + ).rejects.toThrow("Use `sentry auth`"); + }); +}); diff --git a/packages/mcp-server/src/cli/usage.ts b/packages/mcp-server/src/cli/usage.ts index fbdc144df..c49fa4e6d 100644 --- a/packages/mcp-server/src/cli/usage.ts +++ b/packages/mcp-server/src/cli/usage.ts @@ -1,9 +1,64 @@ import type { Skill } from "@sentry/mcp-core/skills"; +type UsageOptions = { + usesHostAuthentication?: boolean; +}; + export function buildUsage( packageName: string, allSkills: ReadonlyArray, + options: UsageOptions = {}, ): string { + if (options.usesHostAuthentication) { + return `Usage: ${packageName} [--host=] [--insecure-http] + +Authentication: + Uses the active Sentry CLI session. Authenticate with \`sentry auth login\`. + +Common optional flags: + --host Change Sentry host (self-hosted) + --insecure-http Use http:// for self-hosted --host values + --sentry-dsn Override DSN used for telemetry reporting + --experimental Enable forward-looking tool variants and experimental features + +Embedded agent configuration: + --agent-provider LLM provider: openai, azure-openai, anthropic, or openrouter (auto-detects from API keys) + --openai-base-url Override OpenAI API base URL + --openai-model Override OpenAI model (default: gpt-5) + --anthropic-base-url Override Anthropic API base URL + --anthropic-model Override Anthropic model (default: claude-sonnet-4-5) + +Session constraints: + --organization-slug Force all calls to an organization + --project-slug Optional project constraint + +Skill controls: + --skills Specify which skills to grant (default: active, non-deprecated skills; + seer is excluded by default on self-hosted hosts) + --all-skills Grant all active, non-deprecated skills (including seer on self-hosted) + --disable-skills Remove specific skills (e.g. --disable-skills=seer) + +Available skills: ${allSkills.join(", ")} + +Environment variables: + OPENAI_API_KEY OpenAI API key for AI-powered search tools + ANTHROPIC_API_KEY Anthropic API key for AI-powered search tools + OPENROUTER_API_KEY OpenRouter API key for AI-powered search tools + OPENROUTER_MODEL OpenRouter model (default: openai/gpt-5.6-luna) + OPENROUTER_REASONING_EFFORT OpenRouter reasoning effort: none|minimal|low|medium|high|xhigh (max→xhigh), or "" to omit (default: high) + EMBEDDED_AGENT_PROVIDER Provider override: openai, azure-openai, anthropic, or openrouter + MCP_SKILLS Specify which skills to grant (comma-separated) + MCP_DISABLE_SKILLS Disable specific skills (comma-separated) + +Examples: + ${packageName} + ${packageName} --all-skills + ${packageName} --skills=inspect,triage + ${packageName} --host=sentry.example.com + ${packageName} --host=sentry.internal:9000 --insecure-http + ${packageName} --agent-provider=anthropic`; + } + return `Usage: ${packageName} [--access-token=] [--host=] [--insecure-http] ${packageName} auth [login|logout|status] diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index d89e305b5..d207bf96d 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -20,6 +20,8 @@ * ``` */ +import { pathToFileURL } from "node:url"; + import { getAgentProvider, getResolvedProviderType, @@ -36,26 +38,63 @@ import { authCommand } from "./cli/commands/auth"; import { printCliLine } from "./cli/output"; import { merge, parseArgv, parseEnv } from "./cli/parse"; import { finalize } from "./cli/resolve"; +import type { PartiallyResolvedConfig } from "./cli/types"; import { buildUsage } from "./cli/usage"; import { startStdio } from "./transports/stdio"; -const packageName = "@sentry/mcp-server"; +const defaultPackageName = "@sentry/mcp-server"; const allSkills = Object.keys(SKILLS) as ReadonlyArray< (typeof SKILLS)[keyof typeof SKILLS]["id"] >; -const usageText = buildUsage(packageName, allSkills); -function die(message: string): never { - console.error(message); - console.error(usageText); - process.exit(1); -} +export type McpServerOptions = { + /** + * Supplies credentials from a host application instead of the standalone + * device-code and cache flow. + */ + resolveAccessToken?: (config: PartiallyResolvedConfig) => Promise; + /** + * Invoked when a tool call surfaces an upstream 401. The host application can + * refresh its credential and call `setAccessToken` so later tool calls use + * the new token without restarting the server. + */ + onUpstreamUnauthorized?: ( + setAccessToken: (token: string) => void, + ) => void | Promise; + /** Command name used in usage output. */ + packageName?: string; + /** Environment used for server configuration. */ + environment?: NodeJS.ProcessEnv; + /** Throw setup errors instead of printing usage and exiting the process. */ + throwOnError?: boolean; +}; + +/** Start the stdio MCP server with either standalone or host-provided auth. */ +export async function runMcpServer( + rawArgs = process.argv.slice(2), + options: McpServerOptions = {}, +) { + const packageName = options.packageName ?? defaultPackageName; + const usageText = buildUsage(packageName, allSkills, { + usesHostAuthentication: options.resolveAccessToken !== undefined, + }); -async function main() { - const rawArgs = process.argv.slice(2); + function die(error: unknown): never { + if (options.throwOnError) { + throw error; + } + console.error(error instanceof Error ? error.message : String(error)); + console.error(usageText); + process.exit(1); + } // Handle subcommands before normal server parsing if (rawArgs[0] === "auth") { + if (options.resolveAccessToken) { + die( + new Error("Use `sentry auth` to manage credentials for `sentry mcp`."), + ); + } await authCommand(rawArgs.slice(1)); return; } @@ -70,25 +109,29 @@ async function main() { process.exit(0); } if (cli.unknownArgs.length > 0) { - console.error("Error: Invalid argument(s):", cli.unknownArgs.join(", ")); - console.error(usageText); - process.exit(1); + die(new Error(`Error: Invalid argument(s): ${cli.unknownArgs.join(", ")}`)); } - const env = parseEnv(process.env); + const env = parseEnv(options.environment ?? process.env); const partialCfg = (() => { try { return finalize(merge(cli, env)); } catch (err) { - die(err instanceof Error ? err.message : String(err)); + die(err); } })(); // Resolve access token before starting the transport. // For sentry.io without a token, this blocks on device code flow — // the client won't connect until the user has authenticated. - const cfg = await resolveAccessToken(partialCfg).catch((err) => { - die(err instanceof Error ? err.message : String(err)); + const cfg = await (options.resolveAccessToken + ? options.resolveAccessToken(partialCfg).then((accessToken) => ({ + ...partialCfg, + accessToken, + })) + : resolveAccessToken(partialCfg) + ).catch((err) => { + die(err); }); // Configure embedded agent provider @@ -383,6 +426,15 @@ async function main() { openaiBaseUrl: cfg.openaiBaseUrl, experimentalMode: cli.experimental, transport: "stdio" as const, + // Let the host refresh its credential on an upstream 401 and write it back + // so subsequent tool calls use the new token. Tool handlers read + // `context.accessToken` fresh per call, so mutating it here is enough. + onUpstreamUnauthorized: options.onUpstreamUnauthorized + ? () => + options.onUpstreamUnauthorized?.((token) => { + context.accessToken = token; + }) + : undefined, }; // Build server with context to filter tools based on granted skills @@ -397,7 +449,12 @@ async function main() { }); } -main().catch((err) => { - console.error("Fatal error:", err); - process.exit(1); -}); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + void runMcpServer().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 018447d54..bbf4eb7ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,6 +292,9 @@ importers: '@sentry/core': specifier: 10.63.0 version: 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) + '@sentry/mcp-server': + specifier: workspace:* + version: link:../mcp-server '@sentry/node': specifier: 10.65.0 version: 10.65.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))