diff --git a/.agents/skills/mcp-qa/SKILL.md b/.agents/skills/mcp-qa/SKILL.md index 9b55fd1b4..8858724f5 100644 --- a/.agents/skills/mcp-qa/SKILL.md +++ b/.agents/skills/mcp-qa/SKILL.md @@ -19,29 +19,27 @@ Fix any failures before proceeding. This is the primary QA path for tool behavior. Stdio runs the local MCP server against prod Sentry without depending on the Cloudflare worker, local `/mcp` route, or Cloudflare OAuth configuration. -When validating code or tool changes, build first because the test client -launches `packages/mcp-server/dist/index.js`: +The test client launches `sentry mcp` from CLI source +(`pnpm --dir packages/cli run cli mcp`), which reads mcp-core's build output, so +build first when validating code or tool changes: ```bash pnpm -w run build ``` -Check auth first: +Check auth first. `sentry mcp` uses the CLI's credentials: ```bash -pnpm --filter @sentry/mcp-server start auth status +pnpm --filter sentry run cli auth status ``` -If no cache exists, warm the device-code cache: +If you are not logged in, log in once (or pass `--access-token` to the test +client): ```bash -pnpm --filter @sentry/mcp-server start auth login +pnpm --filter sentry run cli auth login ``` -Device-code auth uses the bundled stdio public client ID, requires no client -secret, is separate from the Cloudflare OAuth app, and caches the token in -`~/.sentry/mcp.json`. - First prove startup and auth: ```bash @@ -96,9 +94,9 @@ For agent-client-specific behavior, replace the default harness prompt with the same realistic prod prompt used for stdio QA. Passing QA requires the same changed-tool transcript evidence, not only `whoami`. -as an isolated auth cache. Real clients do not give stdio subprocesses a TTY, -so warm the cache before running the harness. It also runs the built -`packages/mcp-server/dist/index.js`, so build first to avoid stale code. +Real clients do not give stdio subprocesses a TTY, so log in with +`sentry auth login` before running the harness. Build first to avoid stale +mcp-core code. ## 4. Cloudflare HTTP Only When Relevant @@ -144,7 +142,7 @@ dev-time source execution: ```bash pnpm -w run build -pnpm --filter @sentry/mcp-server start auth status +pnpm --filter sentry run cli auth status pnpm -w run cli --transport stdio "who am I?" ``` diff --git a/.github/workflows/mcp-server-package.yml b/.github/workflows/mcp-server-package.yml deleted file mode 100644 index 93dc4da2f..000000000 --- a/.github/workflows/mcp-server-package.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: MCP Server Package - -on: - push: - branches: [main] - pull_request: - paths: - - ".github/workflows/mcp-server-package.yml" - - "packages/mcp-core/**" - - "packages/mcp-server/**" - - "packages/mcp-server-mocks/**" - - "packages/mcp-server-tsconfig/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - -permissions: - contents: read - -jobs: - package-consumer: - name: Clean Consumer Typecheck - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - - # pnpm/action-setup@v4 - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda - name: Install pnpm - with: - run_install: false - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build stdio package and dependencies - run: pnpm --filter '@sentry/mcp-server...' --if-present run build - - - name: Pack stdio package - run: pnpm --dir packages/mcp-server pack --pack-destination "$RUNNER_TEMP" - - - name: Typecheck clean consumer - shell: bash - run: | - set -euo pipefail - - consumer="$RUNNER_TEMP/mcp-server-consumer" - mkdir -p "$consumer" - cd "$consumer" - - export npm_config_cache="$RUNNER_TEMP/npm-cache" - cat > package.json <<'EOF' - { - "private": true, - "type": "module" - } - EOF - - npm install --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP"/sentry-mcp-server-*.tgz typescript@^5.8.3 @types/node@^22 - - cat > tsconfig.json <<'EOF' - { - "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", - "noEmit": true, - "strict": true, - "target": "ES2022" - }, - "include": ["index.ts"] - } - EOF - - cat > index.ts <<'EOF' - import { - startStdio, - type StdioServerContext, - } from "@sentry/mcp-server/transports/stdio"; - - declare const server: Parameters[0]; - - const telemetryContext: StdioServerContext = { - experimentalMode: false, - mcpUrl: "https://example.com/mcp", - sentryHost: "sentry.io", - }; - - void startStdio(server, telemetryContext); - void startStdio(server, { - accessToken: "token", - constraints: {}, - sentryHost: "sentry.io", - }); - EOF - - npx tsc --noEmit diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml index c021908dc..980e486c8 100644 --- a/.github/workflows/merge-jobs.yml +++ b/.github/workflows/merge-jobs.yml @@ -26,7 +26,7 @@ jobs: run: pnpm install - name: Build - run: pnpm --filter '@sentry/mcp-server...' --if-present run build + run: pnpm --filter '@sentry/mcp-core...' --if-present run build - name: Run linter run: pnpm lint @@ -34,6 +34,15 @@ jobs: - name: Run tests run: pnpm test:ci + # @sentry/mcp-server bundles the CLI's `sentry mcp` server into one file. + - name: Bundle mcp-server + env: + SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} + run: pnpm --filter @sentry/mcp-server run bundle + + - name: Test mcp-server bundle + run: pnpm --filter @sentry/mcp-server run test:bundle + - name: Package mcp-server working-directory: packages/mcp-server run: pnpm pack --pack-destination dist diff --git a/.gitignore b/.gitignore index 7e000454f..d433329c7 100644 --- a/.gitignore +++ b/.gitignore @@ -77,8 +77,6 @@ coverage .env.sentry-build-plugin # Generated files -packages/mcp-server/src/toolDefinitions.json -packages/mcp-server/src/skillDefinitions.json packages/mcp-server-evals/eval-results.json apps/cli-docs/.astro apps/cli-docs/src/content/docs/commands/ diff --git a/.oxlintrc.json b/.oxlintrc.json index d1b380f4b..1a9766cdc 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -53,6 +53,28 @@ "jsx-a11y/no-static-element-interactions": "off" } }, + { + "files": ["packages/mcp-core/**/*.{ts,tsx,js,mjs,cjs}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "sentry", + "message": "mcp-core and toolkit-core must not import the Sentry CLI. Inject CLI-only capabilities through interfaces defined in mcp-core or toolkit-core (see docs/architecture/overview.md#package-dependency-direction)." + } + ], + "patterns": [ + { + "group": ["sentry/**", "**/cli/src/**", "**/packages/cli/**"], + "message": "mcp-core and toolkit-core must not import the Sentry CLI. Inject CLI-only capabilities through interfaces defined in mcp-core or toolkit-core (see docs/architecture/overview.md#package-dependency-direction)." + } + ] + } + ] + } + }, { "files": [ "packages/cli/src/**/*.{ts,tsx}", @@ -153,6 +175,10 @@ "error", { "paths": [ + { + "name": "sentry", + "message": "mcp-core and toolkit-core must not import the Sentry CLI. Inject CLI-only capabilities through interfaces defined in mcp-core or toolkit-core (see docs/architecture/overview.md#package-dependency-direction)." + }, { "name": "assert", "message": "Shared toolkit-core modules move into @sentry/api and must run in browsers. Use web-standard APIs, or inject the host-specific piece." @@ -267,6 +293,10 @@ } ], "patterns": [ + { + "group": ["sentry/**", "**/cli/src/**", "**/packages/cli/**"], + "message": "mcp-core and toolkit-core must not import the Sentry CLI. Inject CLI-only capabilities through interfaces defined in mcp-core or toolkit-core (see docs/architecture/overview.md#package-dependency-direction)." + }, { "group": ["node:*"], "message": "Shared toolkit-core modules move into @sentry/api and must run in browsers. Use web-standard APIs, or inject the host-specific piece." diff --git a/AGENTS.md b/AGENTS.md index 73ded3708..a4c374916 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ Toolkit contains the Sentry CLI, the Sentry MCP server, and shared code. Product - **MCP tool count**: Target ≤20, hard limit 25 (AI agents have limited tool slots). - **Quality gate**: `pnpm run tsc && pnpm run lint && pnpm run test` must pass before committing. +- **Dependency direction**: `sentry` (cli) → `@sentry/mcp-core` → `@sentry/toolkit-core`, and `@sentry/mcp-cloudflare` → `@sentry/mcp-core`; never the reverse. mcp-core and toolkit-core never import the CLI. Inject CLI-only capabilities (SQLite cache, local-file DSN detection, file uploads) through interfaces defined in mcp-core or toolkit-core. `pnpm run lint` enforces this; see docs/architecture/overview.md#package-dependency-direction. ## Repository Structure @@ -69,7 +70,7 @@ toolkit/ - docs/architecture/overview.md — MCP system design - docs/operations/security.md — Authentication and security patterns -- docs/operations/stdio-auth.md — Device code flow, token caching, client ID architecture +- docs/operations/stdio-auth.md — stdio auth: shared CLI login and static tokens - docs/operations/oauth-signout-playbook.md — Remote OAuth diagnostic runbook - docs/operations/embedded-agents.md — LLM provider configuration for AI-powered tools - docs/operations/github-actions.md — MCP deployment workflows @@ -97,7 +98,7 @@ toolkit/ **MCP releases:** -- docs/releases/stdio.md — npm package release +- docs/releases/stdio.md — `@sentry/mcp-server` release and the MCP registry - docs/releases/cloudflare.md — Cloudflare deployment ## Commands diff --git a/README.md b/README.md index 5ab2e9d7a..f88d3322f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ While this repository is focused on acting as an MCP service, we also support a **Note:** The AI-powered search tools (`search_errors`, `search_traces`, `search_logs`, `search_issues`, etc.) require an LLM provider (OpenAI, Azure OpenAI, Anthropic, or OpenRouter). These tools use natural language processing to translate queries into Sentry's query syntax. Without a configured provider, these specific tools will be unavailable, but all other tools will function normally. -To utilize the `stdio` transport, you'll need to create an User Auth Token in Sentry with the necessary scopes. As of writing this is: +The `stdio` transport ships as [`@sentry/mcp-server`](https://www.npmjs.com/package/@sentry/mcp-server). Sign in once with `npx @sentry/mcp-server@latest auth login`, or pass a static token with `--access-token` / `SENTRY_ACCESS_TOKEN`. The login is shared with the [`sentry` CLI](https://cli.sentry.dev), so an existing `sentry auth login` works too. + +To use a static token, create a User Auth Token in Sentry with the necessary scopes. As of writing this is: ``` org:read @@ -59,6 +61,8 @@ event:write Launch the transport: ```shell +npx @sentry/mcp-server@latest auth login # once, to sign in +npx @sentry/mcp-server@latest # uses that login npx @sentry/mcp-server@latest --access-token=sentry-user-token ``` @@ -118,7 +122,7 @@ tools with `?skills=inspect,triage` or `?disable-skills=seer`. #### Environment Variables ```shell -SENTRY_ACCESS_TOKEN= # Required: Your Sentry auth token +SENTRY_ACCESS_TOKEN= # Optional after `auth login`: static Sentry auth token # LLM Provider Configuration (required for AI-powered search tools) EMBEDDED_AGENT_PROVIDER= # Required when multiple provider keys are set: 'openai', 'azure-openai', 'anthropic', or 'openrouter' @@ -143,7 +147,7 @@ MCP_DISABLE_SKILLS= # Disable specific skills (comma-separated, e.g. 'p "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -166,7 +170,7 @@ self-hosted deployment that does run Seer, opt in with `MCP_SKILLS`: "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "SENTRY_HOST": "sentry.example.com", diff --git a/ast-grep-rules/no-stdio-stdout.yml b/ast-grep-rules/no-stdio-stdout.yml index a93c82d48..c9ba12cec 100644 --- a/ast-grep-rules/no-stdio-stdout.yml +++ b/ast-grep-rules/no-stdio-stdout.yml @@ -10,8 +10,7 @@ rule: - pattern: console.debug($$$ARGS) files: - packages/mcp-core/src/**/*.ts - - packages/mcp-server/src/**/*.ts + - packages/cli/src/lib/mcp/**/*.ts ignores: - - packages/mcp-server/src/cli/** - "**/*.test.ts" - "**/*.d.ts" diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index f095c646a..211c3db42 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -12,8 +12,10 @@ The project is a pnpm monorepo with clear separation of concerns: ``` packages/ +├── cli/ # Sentry CLI, including the `sentry mcp` stdio server +├── toolkit-core/ # Shared CLI/MCP primitives ├── mcp-core/ # Core MCP implementation (private package) -├── mcp-server/ # stdio transport (published as @sentry/mcp-server) +├── mcp-server/ # stdio transport (@sentry/mcp-server on npm) ├── mcp-cloudflare/ # Cloudflare Workers deployment ├── mcp-server-evals/ # Evaluation test suite ├── mcp-server-mocks/ # Shared mock data @@ -31,6 +33,7 @@ src/ ├── api-client/ # Sentry API client ├── internal/ # Utilities (not exposed) ├── tools/ # Tool implementations +├── transports/ # stdio transport ├── toolDefinitions.ts # Tool schemas and metadata ├── server.ts # buildServer() function ├── errors.ts # Custom error types @@ -52,27 +55,21 @@ catalog changes. Modern `subscriptions/listen` acknowledgments therefore omit **Note:** This package is **not published to npm**. It's a workspace-only package. -### packages/mcp-server +### packages/cli (`sentry mcp`) -Published stdio transport package (as `@sentry/mcp-server`). +The local stdio MCP server is the `sentry mcp` command in the Sentry CLI. -**Structure:** +- `src/lib/mcp.ts` resolves credentials from the CLI session (or a static + `--access-token` / `SENTRY_ACCESS_TOKEN`) and lazily loads the host, so normal + CLI startup never loads mcp-core or Zod. +- `src/lib/mcp/` parses server flags and environment variables, configures the + embedded agent provider and telemetry, and starts mcp-core's stdio transport. -``` -src/ -├── cli/ # CLI argument parsing -├── transports/ # stdio transport -└── index.ts # Main entry point -``` - -**Key responsibilities:** - -- CLI entry point for the MCP server -- stdio transport implementation -- Bundles all mcp-core code at build time -- Published to npm for end users +### packages/mcp-server -**Build:** Uses tsdown to bundle mcp-core code, resulting in a self-contained package. +Publishes the stdio server as `@sentry/mcp-server`, the recommended local +install: a single bundled file with no runtime dependencies, built from the +CLI's `src/mcp-server.ts` without the rest of the CLI. See [stdio release](../releases/stdio.md). ### packages/mcp-cloudflare @@ -114,6 +111,32 @@ Interactive CLI for testing the MCP server with an AI agent. - Stdio transport for local testing - Clean terminal output with tool call visualization +## Package Dependency Direction + +Workspace dependencies point one way: + +``` +sentry (packages/cli) ──> @sentry/mcp-core ──> @sentry/toolkit-core +@sentry/mcp-cloudflare ──> @sentry/mcp-core +``` + +mcp-core and toolkit-core never import the CLI, and toolkit-core never imports +mcp-core. When an MCP tool needs a capability only the CLI host has, such as +the SQLite cache, DSN detection from local files, or file uploads, define an +interface in mcp-core or toolkit-core and let the CLI inject an implementation +(the way `sentry mcp` passes the access token and the refresh callback through +`ServerContext`). The hosted server can then supply its own implementation or +leave the capability out. + +Two checks enforce this in `pnpm run lint`: + +- oxlint `no-restricted-imports` (`.oxlintrc.json`) rejects `sentry`, + `sentry/*`, and relative CLI paths inside `packages/mcp-core` and + `packages/toolkit-core`. +- `scripts/check-workspace-deps.mjs` (`pnpm run lint:deps`) reads every + workspace `package.json` and fails on a dependency cycle or a forbidden edge, + such as mcp-core reaching `sentry` or toolkit-core reaching mcp-core. + ## Key Architectural Decisions ### 1. Protocol Implementation diff --git a/docs/contributing/adding-tools.md b/docs/contributing/adding-tools.md index da9f780cd..aa76fff4a 100644 --- a/docs/contributing/adding-tools.md +++ b/docs/contributing/adding-tools.md @@ -253,7 +253,7 @@ describe("your_tool_name", () => { **After changing output, update snapshots:** ```bash -cd packages/mcp-server +cd packages/mcp-core pnpm vitest --run -u ``` diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index 6a00c581e..f4e28975d 100644 --- a/docs/contributing/api-patterns.md +++ b/docs/contributing/api-patterns.md @@ -43,7 +43,7 @@ const api = new SentryApiService({ }); ``` -See `packages/mcp-server/src/api-utils.ts` and +See `packages/mcp-core/src/api-utils.ts` and [adding-tools.md](adding-tools.md#step-2-implement-the-handler) for usage in tools. @@ -327,7 +327,7 @@ applies its own per-request timeout, which counts as a network failure. ## References -- API Client: `packages/mcp-server/src/api-client/` +- API Client: `packages/mcp-core/src/api-client/` - Mock handlers: `packages/mcp-server-mocks/src/handlers/` - Fixtures: `packages/mcp-server-mocks/src/fixtures/` -- API Utils: `packages/mcp-server/src/api-utils.ts` +- API Utils: `packages/mcp-core/src/api-utils.ts` diff --git a/docs/mcp/README.md b/docs/mcp/README.md index a57b1b313..7914c891c 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -1,6 +1,6 @@ # Sentry MCP Documentation -MCP spans `packages/mcp-core/`, `packages/mcp-server/`, and +MCP spans `packages/mcp-core/`, `packages/mcp-server/` (built from `packages/cli/`), and `packages/mcp-cloudflare/`, with mocks, evals, and a test client in neighboring packages. Its guides stay in root `docs/` because no single MCP package owns them. The [Toolkit index](../README.md) also links the CLI and shared guides. diff --git a/docs/operations/embedded-agents.md b/docs/operations/embedded-agents.md index 5bc42b3e6..acdc97fa3 100644 --- a/docs/operations/embedded-agents.md +++ b/docs/operations/embedded-agents.md @@ -59,7 +59,7 @@ Or: ```bash export EMBEDDED_AGENT_PROVIDER=azure-openai export OPENAI_API_KEY=sk-... -npx @sentry/mcp-server --openai-base-url=https://example.openai.azure.com/openai/v1/ +npx @sentry/mcp-server@latest --openai-base-url=https://example.openai.azure.com/openai/v1/ ``` Or: @@ -83,7 +83,7 @@ export EMBEDDED_AGENT_PROVIDER=openrouter #### Method 2: CLI Flag ```bash -npx @sentry/mcp-server --agent-provider=openai --access-token=... +npx @sentry/mcp-server@latest --agent-provider=openai --access-token=... ``` #### Method 3: MCP Configuration File @@ -95,7 +95,7 @@ In your MCP settings (e.g., Claude Desktop config): "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -137,7 +137,7 @@ Always set `EMBEDDED_AGENT_PROVIDER` when multiple API keys might be present: "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -194,7 +194,7 @@ For custom OpenAI-compatible endpoints or Anthropic proxies: ```bash export EMBEDDED_AGENT_PROVIDER=openai export OPENAI_API_KEY=sk-... -npx @sentry/mcp-server --openai-base-url=https://custom.openai.example.com +npx @sentry/mcp-server@latest --openai-base-url=https://custom.openai.example.com ``` For Azure OpenAI or Azure-compatible deployment proxies, use the dedicated @@ -206,10 +206,10 @@ export OPENAI_API_KEY=sk-... export OPENAI_API_VERSION=2024-02-15-preview # Azure v1 endpoint -> Responses API -npx @sentry/mcp-server --openai-base-url=https://example.openai.azure.com/openai/v1/ +npx @sentry/mcp-server@latest --openai-base-url=https://example.openai.azure.com/openai/v1/ # Deployment endpoint -> chat completions -npx @sentry/mcp-server --openai-base-url=https://example.openai.azure.com/openai/deployments/my-assistant +npx @sentry/mcp-server@latest --openai-base-url=https://example.openai.azure.com/openai/deployments/my-assistant ``` ### Model Selection @@ -233,7 +233,7 @@ export OPENROUTER_REASONING_EFFORT=high Check which provider is being used: ```bash -npx @sentry/mcp-server --access-token=... 2>&1 | grep "Using" +npx @sentry/mcp-server@latest --access-token=... 2>&1 | grep "Using" ``` Expected output: @@ -290,7 +290,7 @@ export ANTHROPIC_API_KEY=sk-ant-test export OPENAI_API_KEY=sk-test export EMBEDDED_AGENT_PROVIDER=openai -npx @sentry/mcp-server --access-token=... +npx @sentry/mcp-server@latest --access-token=... ``` Should output: diff --git a/docs/operations/logging.md b/docs/operations/logging.md index e1ce17e71..8d97bcbc8 100644 --- a/docs/operations/logging.md +++ b/docs/operations/logging.md @@ -16,7 +16,7 @@ Log levels are controlled by: **Important**: We use a custom LogTape sink that calls `Sentry.logger.*` methods to send logs to Sentry's Logs product. The `@logtape/sentry` package uses `captureException/captureMessage` which creates Issues instead of Logs. -Implementation: `packages/mcp-server/src/telem/logging.ts` +Implementation: `packages/mcp-core/src/telem/logging.ts` ## Stdio Transport Invariant: Logs MUST Go to Stderr @@ -37,7 +37,7 @@ Regression history: #922 — LogTape's default level map sent `info`/`debug` rec ### logInfo() - Routine telemetry ```typescript -import { logInfo } from "@sentry/mcp-server/telem/logging"; +import { logInfo } from "@sentry/mcp-core/telem/logging"; logInfo("MCP server started", { loggerScope: ["server", "lifecycle"], @@ -48,7 +48,7 @@ logInfo("MCP server started", { ### logWarn() - Operational warnings ```typescript -import { logWarn } from "@sentry/mcp-server/telem/logging"; +import { logWarn } from "@sentry/mcp-core/telem/logging"; logWarn("Rate limit approaching", { loggerScope: ["api", "rate-limit"], @@ -59,7 +59,7 @@ logWarn("Rate limit approaching", { ### logError() - Operational errors (no Sentry issue) ```typescript -import { logError } from "@sentry/mcp-server/telem/logging"; +import { logError } from "@sentry/mcp-core/telem/logging"; logError(error, { loggerScope: ["tools", "fetch-trace"], @@ -70,7 +70,7 @@ logError(error, { ### logIssue() - System errors (creates Sentry issue) ```typescript -import { logIssue } from "@sentry/mcp-server/telem/logging"; +import { logIssue } from "@sentry/mcp-core/telem/logging"; const eventId = logIssue(error, { loggerScope: ["oauth", "token-refresh"], @@ -156,7 +156,7 @@ app.use(createRequestLogger(["cloudflare", "http"])); ## References -- Implementation: `packages/mcp-server/src/telem/logging.ts` +- Implementation: `packages/mcp-core/src/telem/logging.ts` - Error handling patterns: [Error Handling](../contributing/error-handling.md) - Monitoring and tracing: [Monitoring](monitoring.md) - LogTape docs: https://logtape.org/ diff --git a/docs/operations/monitoring.md b/docs/operations/monitoring.md index 31bd2c1fc..fe9c08575 100644 --- a/docs/operations/monitoring.md +++ b/docs/operations/monitoring.md @@ -15,7 +15,7 @@ Different Sentry SDKs for different environments: ### Error Logging -See `logIssue` in `packages/mcp-server/src/telem/logging.ts` (documented in [Logging Reference](logging.md)) for the canonical way to create an Issue and structured log entry. +See `logIssue` in `packages/mcp-core/src/telem/logging.ts` (documented in [Logging Reference](logging.md)) for the canonical way to create an Issue and structured log entry. For event schema failures, see [Event Validation Diagnostics](../../TELEMETRY.md#event-validation-diagnostics) for the `contextType` diagnostic and its privacy constraints. @@ -365,7 +365,7 @@ NODE_ENV=production ## References -- Core logging: `packages/mcp-server/src/logging.ts` +- Core logging: `packages/mcp-core/src/logging.ts` - Worker config: `packages/mcp-cloudflare/src/server/sentry.config.ts` -- Tracing helpers: `packages/mcp-server/src/tracing.ts` +- Tracing helpers: `packages/mcp-core/src/tracing.ts` - Sentry docs: https://docs.sentry.io/ diff --git a/docs/operations/security.md b/docs/operations/security.md index 670ce6120..bebaee54d 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -229,7 +229,7 @@ validateRegionUrl("https://eu.sentry.io", "sentry.io"); // ❌ Not in allowlist validateRegionUrl("https://sub.mycompany.com", "mycompany.com"); // ❌ Not base host or allowlist ``` -Implementation: `packages/mcp-server/src/internal/tool-helpers/validate-region-url.ts` +Implementation: `packages/mcp-core/src/internal/tool-helpers/validate-region-url.ts` ### Prompt Injection Protection diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index 7deaa2c74..14c9f2afc 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -1,141 +1,78 @@ # Stdio Authentication -How the stdio transport authenticates with Sentry, including the device code flow, token caching, and the relationship between the bundled client ID and the Cloudflare OAuth app. - -## Authentication Methods - -The stdio transport supports two authentication methods, in order of precedence: - -1. **Explicit token** — `--access-token` flag or `SENTRY_ACCESS_TOKEN` env var. Works for all Sentry hosts. This is the only method available for self-hosted instances. - For self-hosted deployments that only expose plain HTTP, pair `--access-token` with `--host` and `--insecure-http`. - -2. **Device code flow** — OAuth Device Authorization Grant (RFC 8628). Only available for sentry.io (including regional subdomains like `us.sentry.io`). Requires an interactive terminal (TTY). Falls back to cached tokens in non-interactive contexts. - -## Device Code Flow - -When no token is provided and the host is sentry.io, the CLI initiates the device code flow: - -``` -CLI Sentry (sentry.io) - │ │ - │ POST /oauth/device/code/ │ - │ (client_id, scope) │ - │──────────────────────────────>│ - │ │ - │ device_code, user_code, │ - │ verification_uri_complete │ - │<──────────────────────────────│ - │ │ - │ Display code to user │ - │ Open browser to verify URL │ - │ │ - │ POST /oauth/token/ │ (poll every N seconds) - │ (device_code, client_id) │ - │──────────────────────────────>│ - │ │ - │ authorization_pending │ (user hasn't authorized yet) - │<──────────────────────────────│ - │ │ - │ ... poll again ... │ - │ │ - │ access_token, refresh_token │ (user authorized) - │<──────────────────────────────│ - │ │ - │ Cache token to disk │ - │ Start MCP server │ -``` - -### OAuth Endpoints - -All OAuth requests target `sentry.io` directly (via `OAUTH_HOST` constant), regardless of regional subdomain configuration. The device code and token endpoints live on the main domain only. - -### Scopes - -The device code flow requests the same scopes as the Cloudflare OAuth app: - -``` -org:read project:write team:write event:write alerts:write -``` - -These are defined once in `packages/mcp-core/src/scopes.ts` and shared by both transports. - -## Client ID - -A bundled `DEFAULT_SENTRY_CLIENT_ID` ships in `packages/mcp-server/src/auth/constants.ts`. This is a public OAuth application registered on sentry.io specifically for the stdio device code flow. It has no client secret — device code flow doesn't use one. - -The Cloudflare deployment uses a separate OAuth application (configured via `SENTRY_CLIENT_ID` + `SENTRY_CLIENT_SECRET` env vars) because it uses the authorization code grant which requires a secret. - -Both transports use the `SENTRY_CLIENT_ID` env var name for overriding, but they're separate deployments with separate values: - -| Transport | Default client ID | Grant type | Client secret | -| ---------- | ------------------------------ | ---------------------- | ------------------------------ | -| stdio | Bundled in `auth/constants.ts` | Device code (RFC 8628) | None (public client) | -| Cloudflare | `SENTRY_CLIENT_ID` env var | Authorization code | `SENTRY_CLIENT_SECRET` env var | - -## Token Cache - -Tokens are cached at `~/.sentry/mcp.json` to avoid re-authentication on every server start. - -### File Format - -```json -{ - "sentry.io:client-id-here": { - "access_token": "...", - "refresh_token": "...", - "expires_at": "2026-04-25T...", - "sentry_host": "sentry.io", - "client_id": "...", - "user_email": "user@example.com", - "scope": "org:read project:write team:write event:write alerts:write" - } -} -``` - -Cache entries are keyed by `{host}:{clientId}` so different hosts or client IDs don't collide. - -### Security - -- File permissions: `0o600` (owner read/write only) -- Directory permissions: `0o700` -- Writes are atomic (temp file + rename) -- Tokens are treated as expired 5 minutes before actual expiry - -### Override - -Set `SENTRY_MCP_AUTH_CACHE` to override the cache file path (primarily for testing). - -## Non-Interactive Contexts - -When stderr is not a TTY (CI, piped stdio, MCP inspector), the device code flow is not started — it would hang waiting for a human. Instead: - -1. If a cached token exists, it's used silently. -2. If no cached token exists, the process exits with an error directing the user to run `sentry-mcp auth login` interactively first. - -## Auth CLI Commands - -The CLI exposes auth management as subcommands: - -```bash -sentry-mcp auth login # Force device code flow (always re-authenticates) -sentry-mcp auth logout # Clear cached token -sentry-mcp auth status # Show current auth state -``` - -These commands support `--host` and `--url` flags with the same precedence as the main server: `--url` beats `--host`, CLI beats env vars. +How the stdio MCP server (`@sentry/mcp-server`) authenticates with Sentry. + +The server has no auth flow or token cache of its own. It reuses the Sentry +CLI's credential store: `npx -y @sentry/mcp-server auth login` runs the CLI's +device flow, and a login from `sentry auth login` works too. The same server +also runs as `sentry mcp` in the CLI. + +## Credential Sources + +In order of precedence: + +1. **Static token** — `--access-token` flag, then `SENTRY_ACCESS_TOKEN`. The + token is used as given for the life of the server and is never + refreshed. Org auth tokens (`sntrys_`) record the Sentry URL they were + issued for; the server refuses to send one to a different host + (`HostScopeError`). `SENTRY_ACCESS_TOKEN` only affects the MCP server; other + CLI commands ignore it. +2. **Shared login** — the stored account from `auth login`, or + `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` (see the CLI's + `SENTRY_FORCE_ENV_TOKEN` rules). These go through the CLI's + `refreshToken()`, which enforces the same host scoping as every CLI command. + When a tool call gets a 401 from Sentry, the server forces a refresh and + later calls use the new token, so a long-running server survives token + rotation without a restart. + +When no credential is available and stdin is a TTY, the server starts the +interactive login before serving. MCP clients launch the server over +pipes, so in that case it exits with an error that names the login command: +`npx -y @sentry/mcp-server auth login` from `@sentry/mcp-server`, and +`sentry auth login` from `sentry mcp`. Other auth hints follow the same rule +(`packages/cli/src/lib/login-command.ts`), except that confirming a self-hosted host with +`--url` always names `npx -y sentry auth login --url`, because the +standalone `auth login` takes no flags. + +For self-hosted Sentry, set the host with `--host`/`--url` or +`SENTRY_HOST`/`SENTRY_URL`. Only the environment variables also pick the stored +account for that server (see [Accounts](#accounts)). Pair `--host` with `--insecure-http` for instances +that only expose plain HTTP. + +A repo-local `.sentryclirc` URL is not a trust source. Unless `--host`/`--url` +is passed, a non-SaaS `[defaults] url` must match a server you are logged in +to, the same check every CLI command applies. Otherwise the server refuses to +start (`HostScopeError`) instead of sending any token, including a static one, +to that host. + +## Accounts + +The credential store can hold several accounts, but each server process uses +one: the account resolved at startup. With `SENTRY_HOST`/`SENTRY_URL` set, that +is a stored account on that server; otherwise it is the active account +(`sentry auth switch`). Refreshes after a 401 stay on that account, and tool +calls never pick a different one, even when another process switches or adds +accounts. If that account is removed or another user logs in over it, the +refresh fails and tool calls keep failing until the server restarts. To use +two accounts at once, configure two +MCP servers, for example one per host via `SENTRY_HOST`, or one with a static +`SENTRY_ACCESS_TOKEN`. + +## Upgrading From Older `@sentry/mcp-server` Versions + +Older releases had their own device code flow, bundled client ID, and token +cache (`~/.sentry/mcp.json`, `SENTRY_MCP_AUTH_CACHE`). Current releases do not +read that cache. Run `npx -y @sentry/mcp-server auth login` once, or keep +passing `--access-token` / `SENTRY_ACCESS_TOKEN`. `auth logout` and +`auth status` are not part of the package; use `npx -y sentry auth logout` or +`npx -y sentry auth status`. ## Key Files -| File | Purpose | -| -------------------------------------------------- | --------------------------------------------------------- | -| `packages/mcp-server/src/auth/constants.ts` | Bundled client ID, OAuth endpoints, scopes | -| `packages/mcp-server/src/auth/device-code-flow.ts` | RFC 8628 implementation (request, poll, browser open) | -| `packages/mcp-server/src/auth/token-cache.ts` | Persistent token storage at `~/.sentry/mcp.json` | -| `packages/mcp-server/src/auth/resolve-token.ts` | Orchestration: cache check → TTY check → device code flow | -| `packages/mcp-server/src/auth/types.ts` | Zod schemas for API responses, `CachedToken` type | -| `packages/mcp-server/src/cli/commands/auth.ts` | `auth login/logout/status` subcommands | -| `packages/mcp-core/src/scopes.ts` | Shared OAuth scope definitions | - -## Token Refresh - -Not implemented. Sentry access tokens last 30 days. When a cached token expires, it's cleared and a new device code flow is triggered. This matches the Cloudflare transport's approach (see `packages/mcp-cloudflare/src/server/oauth/helpers.ts`). +| File | Purpose | +| ------------------------------------------- | -------------------------------------------------------------- | +| `packages/cli/src/lib/mcp.ts` | Credential resolution, static-token host check, refresh-on-401 | +| `packages/cli/src/lib/mcp/server.ts` | Flag/env parsing, telemetry, and stdio startup | +| `packages/cli/src/lib/db/auth.ts` | CLI token storage and `refreshToken()` | +| `packages/cli/src/lib/token-host.ts` | Host-scoping checks | +| `packages/mcp-core/src/transports/stdio.ts` | stdio transport and per-request auth context | diff --git a/docs/releases/cloudflare.md b/docs/releases/cloudflare.md index 920a3e4eb..f97984525 100644 --- a/docs/releases/cloudflare.md +++ b/docs/releases/cloudflare.md @@ -75,7 +75,7 @@ The MCP handler uses a stateless architecture with closure-captured context: ```typescript import { experimental_createMcpHandler as createMcpHandler } from "agents/mcp"; -import { buildServer } from "@sentry/mcp-server/server"; +import { buildServer } from "@sentry/mcp-core/server"; const mcpHandler: ExportedHandler = { async fetch( diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index ee2889151..9a4adbe56 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -1,105 +1,62 @@ # stdio Release -npm package release process for the MCP server stdio transport. +How the local stdio MCP server ships. ## Overview -The MCP server is published to npm as `@sentry/mcp-server` for use with: - -- Claude Desktop -- Cursor IDE -- VS Code with MCP extension -- Other MCP clients supporting stdio transport - -## Package Structure - -Published package includes: - -- Compiled TypeScript (`dist/`) -- stdio transport implementation -- Type definitions -- Tool definitions - -## Release Process - -### 1. Version Bump - -Update version in `packages/mcp-server/package.json`: - -```json -{ - "name": "@sentry/mcp-server", - "version": "1.2.3" -} -``` - -Follow semantic versioning: - -- **Major**: Breaking changes to tool interfaces -- **Minor**: New tools or non-breaking features -- **Patch**: Bug fixes - -### 2. Update Changelog - -Document changes in `CHANGELOG.md`: - -```markdown -## [1.2.3] - 2025-01-16 - -### Added - -- New `search_docs` tool for documentation search - -### Fixed - -- Fix context propagation in tool handlers -``` - -### 3. Quality Checks - -**MANDATORY before publishing:** - -```bash -pnpm -w run lint:fix # Fix linting issues -pnpm tsc --noEmit # TypeScript type checking -pnpm test # Run all tests -pnpm run build # Ensure clean build -``` - -All checks must pass. - -### 4. Publish to npm - -```bash -cd packages/mcp-server - -# Dry run to verify package contents -npm publish --dry-run - -# Publish to npm -npm publish -``` - -### 5. Tag Release - -```bash -git tag v1.2.3 -git push origin v1.2.3 -``` +The stdio MCP server ships as `@sentry/mcp-server`, released from the +`packages/mcp-server` Craft workspace. Its source lives in `packages/cli` so it +shares the CLI's auth, which also exposes it as `sentry mcp` in each CLI +release. + +It bundles `@sentry/mcp-core` (tools, server, and the stdio transport) and +accepts these credentials: + +- the shared login from `npx @sentry/mcp-server auth login` or `sentry auth + login` (refreshed automatically, including after an upstream 401) +- `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`, as for every CLI command +- `--access-token` / `SENTRY_ACCESS_TOKEN`: a static token for the MCP server + only. It is never refreshed, and org auth tokens (`sntrys_`) are still + scoped to the host they were issued for. + +## Bundle + +The package is a single file with no runtime dependencies. +`packages/cli/script/bundle-mcp-server.ts` bundles +`packages/cli/src/mcp-server.ts` into `dist/sentry-mcp.cjs`: + +- Full flags, environment variables, tools, and self-hosted support, using the + CLI's credential store. +- `sentry-mcp auth login` runs the CLI's device flow with plain-text output. + Other `auth` subcommands point to `npx -y sentry auth ...`. +- CLI-only code stays out: commands, the markdown renderer, agent skills, + completions, terminal images, the Ink UI, and debug-file processing. The + SQLite WASM fallback is left out too, so the package needs Node.js 22.15+. + The bundle script fails if any of these reach the output or the file grows + past its size budget. + +`pnpm --filter @sentry/mcp-server run bundle` builds it and `run test:bundle` +smoke-tests the result. The `packages/mcp-server` Craft workspace releases it +from `release/mcp` branches; `.github/workflows/merge-jobs.yml` bundles, tests, +and packs it. Each release bundles the CLI source at that commit, so it does +not wait for a CLI release. ## MCP Registry -The **Publish MCP Registry** workflow publishes `server.json` on stable GitHub -releases using GitHub Actions OIDC; no registry secret is needed. It reads the -manifest from `main` and sets both versions from the release, so there is no -extra version bump to maintain. The listing includes npm and the hosted endpoint. +The **Publish MCP Registry** workflow publishes `server.json` on stable +`@sentry/mcp-server` releases (not `cli@` tags) using GitHub Actions OIDC; no +registry secret is needed. It reads the manifest from `main` and sets both +versions from the release, so there is no extra version bump to maintain. The +listing points at the `@sentry/mcp-server` npm package, plus the hosted +endpoint. npm verifies ownership through `mcpName` in +`packages/mcp-server/package.json`. Craft publishes GitHub before npm. The workflow waits for the npm package before publishing the listing. Check this Actions run separately from the Craft release. -To retry or publish an existing release after merging the workflow: +To retry or publish an existing release: ```bash -gh workflow run mcp-registry.yml --repo getsentry/sentry-mcp --ref main -f version=0.40.0 +gh workflow run mcp-registry.yml --repo getsentry/toolkit --ref main -f version=0.42.0 ``` An identical active listing is safe to rerun. Different metadata for an existing @@ -108,127 +65,37 @@ changes with the next release. Registry failures do not undo the npm release. ## User Installation -Users install via npx in their MCP client configuration: - -### Claude Desktop +Users run the server through npx in their MCP client configuration: ```json { "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "@sentry/mcp-server"], - "env": { - "SENTRY_ACCESS_TOKEN": "sntrys_...", - "SENTRY_HOST": "sentry.io" - } + "args": ["@sentry/mcp-server@latest"] } } } ``` -Config location: - -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -### Cursor IDE +Sign in once with `npx @sentry/mcp-server@latest auth login` (or +`sentry auth login`), or add `"env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." }`. +Pin a version with `@sentry/mcp-server@0.42.0`. -Add to `.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "sentry": { - "command": "npx", - "args": ["-y", "@sentry/mcp-server"], - "env": { - "SENTRY_ACCESS_TOKEN": "sntrys_...", - "SENTRY_HOST": "sentry.io" - } - } - } -} -``` - -## Environment Variables - -Required: - -- `SENTRY_ACCESS_TOKEN` - Sentry API access token -- `SENTRY_HOST` - Sentry instance hostname (default: `sentry.io`) - -Optional: - -- `SENTRY_ORG` - Default organization slug -- `SENTRY_PROJECT` - Default project slug -- `MCP_DISABLE_SKILLS` - Disable specific skills, comma-separated (e.g. `seer`) - -## Version Pinning - -Users can pin to specific versions: - -```json -{ - "args": ["-y", "@sentry/mcp-server@1.2.3"] -} -``` - -## Testing Releases - -### Local Testing Before Publishing - -Test the built package locally: +## Testing Before a Release ```bash -cd packages/mcp-server -npm pack -# Creates sentry-mcp-server-1.2.3.tgz - -# Test installation -npm install -g ./sentry-mcp-server-1.2.3.tgz +# Run the server from source +pnpm --silent --dir packages/cli run cli mcp --help -# Run stdio server -SENTRY_ACCESS_TOKEN=... @sentry/mcp-server +# Exercise it with the test client +pnpm -w run cli --transport stdio "who am I?" ``` -### Beta Releases - -For testing with users before stable release: - -```bash -npm publish --tag beta -``` - -Users install with: - -```json -{ - "args": ["-y", "@sentry/mcp-server@beta"] -} -``` - -## Troubleshooting - -### Package Not Found - -- Verify package name: `@sentry/mcp-server` (with scope) -- Check npm registry: `npm view @sentry/mcp-server` - -### Version Mismatch - -- Users may have cached version: `npx clear-npx-cache` -- Recommend version pinning for stability - -### Build Failures - -- Ensure `pnpm run build` succeeds before publishing -- Check TypeScript compilation errors -- Verify all dependencies are listed in package.json - ## References -- Package config: `packages/mcp-server/package.json` -- stdio transport: `packages/mcp-server/src/transports/stdio.ts` -- Build script: `packages/mcp-server/scripts/build.ts` -- npm publishing docs: https://docs.npmjs.com/cli/publish +- Command entry: `packages/cli/src/lib/mcp.ts` +- Host (flags, env, telemetry): `packages/cli/src/lib/mcp/` +- stdio transport: `packages/mcp-core/src/transports/stdio.ts` +- `@sentry/mcp-server` entry: `packages/cli/src/mcp-server.ts` +- `@sentry/mcp-server` bundle: `packages/cli/script/bundle-mcp-server.ts` diff --git a/docs/specs/embedded-agent-openai-routing.md b/docs/specs/embedded-agent-openai-routing.md index e247ac21d..0c66639c8 100644 --- a/docs/specs/embedded-agent-openai-routing.md +++ b/docs/specs/embedded-agent-openai-routing.md @@ -124,10 +124,9 @@ Update: - `packages/mcp-core/src/internal/agents/types.ts` - `packages/mcp-core/src/internal/agents/provider-factory.ts` -- `packages/mcp-server/src/cli/types.ts` -- `packages/mcp-server/src/cli/parse.ts` -- `packages/mcp-server/src/cli/resolve.ts` -- `packages/mcp-server/src/cli/usage.ts` +- `packages/cli/src/lib/mcp/args.ts` +- `packages/cli/src/lib/mcp/config.ts` +- `packages/cli/src/lib/mcp/usage.ts` Add: @@ -156,7 +155,7 @@ The key requirement is conceptual separation, not the specific SDK choice. ### Direct OpenAI ```bash -npx @sentry/mcp-server --access-token=TOKEN +npx @sentry/mcp-server@latest --access-token=TOKEN ``` Result: auto-detects `openai` when `OPENAI_API_KEY` is present and uses @@ -165,7 +164,7 @@ Responses API. ### Generic OpenAI-Compatible Proxy ```bash -npx @sentry/mcp-server \ +npx @sentry/mcp-server@latest \ --access-token=TOKEN \ --agent-provider=openai \ --openai-base-url=https://proxy.example.com/v1 \ @@ -178,7 +177,7 @@ deployment alias. ### Azure-Compatible Deployment Proxy ```bash -npx @sentry/mcp-server \ +npx @sentry/mcp-server@latest \ --access-token=TOKEN \ --agent-provider=azure-openai \ --openai-base-url=https://proxy.example.com/openai/deployments/my-assistant \ @@ -193,7 +192,7 @@ alias. ```bash export OPENAI_API_VERSION=2024-02-15-preview -npx @sentry/mcp-server \ +npx @sentry/mcp-server@latest \ --access-token=TOKEN \ --agent-provider=azure-openai \ --openai-base-url=https://my-resource.openai.azure.com/openai/v1/ \ diff --git a/docs/testing/overview.md b/docs/testing/overview.md index e81d6f128..91d906de2 100644 --- a/docs/testing/overview.md +++ b/docs/testing/overview.md @@ -246,7 +246,7 @@ For MCP tools specifically: When output changes are intentional: ```bash -cd packages/mcp-server +cd packages/mcp-core pnpm vitest --run -u ``` @@ -384,7 +384,7 @@ Coverage requirements: ## References -- Test setup: `packages/mcp-server/src/test-utils/` +- Test setup: `packages/mcp-core/src/test-utils/` - Mock server: `packages/mcp-server-mocks/` - Eval tests: `packages/mcp-server-evals/` - Vitest docs: https://vitest.dev/ diff --git a/docs/testing/remote.md b/docs/testing/remote.md index f56b51164..2e9818f98 100644 --- a/docs/testing/remote.md +++ b/docs/testing/remote.md @@ -647,7 +647,7 @@ pnpm dev # Example: Don't query all projects repeatedly # For testing, use mocked responses instead -cd packages/mcp-server +cd packages/mcp-core pnpm test ``` diff --git a/docs/testing/stdio.md b/docs/testing/stdio.md index f2fac99ac..5cba855b2 100644 --- a/docs/testing/stdio.md +++ b/docs/testing/stdio.md @@ -4,7 +4,7 @@ Complete playbook for building, running, and testing the stdio MCP server. ## Overview -The stdio transport runs the MCP server as a subprocess that communicates via stdin/stdout pipes. This is the standard way IDEs and local tools integrate with MCP servers. +The stdio transport runs the MCP server as a subprocess that communicates via stdin/stdout pipes. This is the standard way IDEs and local tools integrate with MCP servers. The stdio server is implemented in the Sentry CLI (`packages/cli`) on top of `@sentry/mcp-core`. It is published as `@sentry/mcp-server` and is also available as `sentry mcp`. **When to use stdio:** @@ -24,7 +24,8 @@ The stdio transport runs the MCP server as a subprocess that communicates via st - Node.js 22.13+ - pnpm installed -- Sentry access token with appropriate scopes +- A Sentry login (`pnpm --filter sentry run cli auth login`) or a Sentry access + token with appropriate scopes ### Required Scopes @@ -40,89 +41,37 @@ For full functionality, create a Sentry User Auth Token with: For read-only testing, use just: `org:read`, `project:read`, `team:read`, `event:read` -## Build Process - -### 1. Initial Setup - -```bash -# Clone and install dependencies -cd sentry-mcp -pnpm install - -# Set up environment (optional for stdio) -make setup-env -``` - -### 2. Build the Package - -```bash -# Build all packages (includes mcp-server) -pnpm -w run build - -# Or build just mcp-server -cd packages/mcp-server -pnpm build -``` - -The build process: - -1. Generates tool definitions (`pnpm run generate-definitions`) -2. Compiles TypeScript to JavaScript -3. Creates both ESM and CJS outputs -4. Generates type declarations -5. Makes the CLI executable - -**Output location:** `packages/mcp-server/dist/` - -### 3. Verify Build - -```bash -# Check the built executable exists -ls -la packages/mcp-server/dist/index.js - -# Test the CLI help -node packages/mcp-server/dist/index.js --help -``` - ## Running Stdio Locally -### Option 1: Using pnpm start (Development) - -Best for active development with TypeScript sources: +### 1. Setup ```bash -cd packages/mcp-server -pnpm start --access-token=YOUR_TOKEN +pnpm install +pnpm -w run build # builds mcp-core, which `sentry mcp` imports ``` -This uses `tsx` to run TypeScript directly without building. - -### Option 2: Using the Built Package (Production-like) - -Test the actual build output: +### 2. Run From Source ```bash -# From repo root -node packages/mcp-server/dist/index.js --access-token=YOUR_TOKEN - -# Or use the workspace command -pnpm -w run mcp-server --access-token=YOUR_TOKEN +# From the repo root. Uses your CLI login when no token is passed. +pnpm --silent --dir packages/cli run cli mcp --help +pnpm --silent --dir packages/cli run cli mcp +pnpm --silent --dir packages/cli run cli mcp --access-token=YOUR_TOKEN ``` -### Option 3: Using npx (End-user Experience) +`--silent` keeps pnpm's banner off stdout, which carries JSON-RPC frames. -Test the published package experience: +### 3. Run the Published Package ```bash -# Latest from npm +npx @sentry/mcp-server@latest npx @sentry/mcp-server@latest --access-token=YOUR_TOKEN - -# Test local build (after packing) -cd packages/mcp-server -pnpm pack -npx ./sentry-mcp-server-*.tgz --access-token=YOUR_TOKEN ``` +To test a local `@sentry/mcp-server` bundle, build it with +`pnpm --filter @sentry/mcp-server run bundle`, then run +`node packages/mcp-server/dist/sentry-mcp.cjs`. + ## Testing with MCP Inspector The MCP Inspector is the best tool for interactive testing of the stdio transport. @@ -130,8 +79,8 @@ The MCP Inspector is the best tool for interactive testing of the stdio transpor ### 1. Start the Inspector ```bash -# From repo root -pnpm inspector +# From repo root; launches `sentry mcp` from source +pnpm inspector:stdio ``` This opens the MCP Inspector at `http://localhost:6274` @@ -150,21 +99,12 @@ This opens the MCP Inspector at `http://localhost:6274` { "command": "pnpm", "args": [ + "--silent", "--dir", - "/absolute/path/to/sentry-mcp/packages/mcp-server", - "start", - "--access-token=YOUR_TOKEN" - ] -} -``` - -**For built package:** - -```json -{ - "command": "node", - "args": [ - "/absolute/path/to/sentry-mcp/packages/mcp-server/dist/index.js", + "/absolute/path/to/toolkit/packages/cli", + "run", + "cli", + "mcp", "--access-token=YOUR_TOKEN" ] } @@ -400,26 +340,31 @@ Add to `.vscode/settings.json`: ## Configuration Options +Run `pnpm --silent --dir packages/cli run cli mcp --help` for the full list. + ### Command-Line Flags ```bash -# Basic usage ---access-token=TOKEN # Sentry access token (required) +# Authentication (optional with a CLI login) +--access-token=TOKEN # Static token; never refreshed # Host configuration --host=sentry.example.com # Self-hosted Sentry (hostname only) +--url=https://sentry.example.com # Self-hosted Sentry (full URL) +--insecure-http # Allow http:// for --host # Skills management --skills=inspect,docs,triage # Limit to specific skills (default: all available) +--disable-skills=seer # Remove skills from the default set # AI features (optional) +--agent-provider=openai # openai, azure-openai, or anthropic --openai-base-url=URL # Custom OpenAI endpoint # Sentry reporting --sentry-dsn=DSN # Custom Sentry DSN for telemetry --sentry-dsn= # Disable telemetry - # Help --help # Show all options --version # Show version @@ -428,14 +373,16 @@ Add to `.vscode/settings.json`: ### Environment Variables ```bash -# Authentication -SENTRY_ACCESS_TOKEN=your-token +# Authentication (optional with a CLI login) +SENTRY_ACCESS_TOKEN=your-token # Same as --access-token +SENTRY_AUTH_TOKEN=your-token # Regular CLI auth, refreshed like any CLI command # Host (self-hosted only) SENTRY_HOST=sentry.example.com # Skills MCP_SKILLS=inspect,docs,triage # Limit to specific skills +MCP_DISABLE_SKILLS=seer # AI features OPENAI_API_KEY=your-key # For AI-powered search tools like search_errors/search_traces/search_issues @@ -455,22 +402,14 @@ SENTRY_DSN=your-dsn pnpm -w run build && pnpm -w run test # Quick smoke test -cd packages/mcp-server -pnpm start --access-token=TOKEN & -sleep 2 -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | pnpm start --access-token=TOKEN +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \ + pnpm --silent --dir packages/cli run cli mcp --access-token=TOKEN ``` ### 2. Test Against Self-Hosted Sentry ```bash -# Development server -pnpm start \ - --access-token=TOKEN \ - --host=sentry.local.dev - -# Built package -node dist/index.js \ +pnpm --silent --dir packages/cli run cli mcp \ --access-token=TOKEN \ --host=sentry.local.dev ``` @@ -478,70 +417,36 @@ node dist/index.js \ ### 3. Test Skills ```bash -# Test with all skills (default) -pnpm start --access-token=TOKEN - # Test with specific skills only -pnpm start --access-token=TOKEN --skills=inspect,docs +pnpm --silent --dir packages/cli run cli mcp --skills=inspect,docs # Test read-only skills -pnpm start --access-token=TOKEN --skills=inspect,seer,docs +pnpm --silent --dir packages/cli run cli mcp --skills=inspect,seer,docs ``` ### 4. Test AI-Powered Tools ```bash -# With OpenAI API key -OPENAI_API_KEY=your-key pnpm start --access-token=TOKEN +OPENAI_API_KEY=your-key pnpm --silent --dir packages/cli run cli mcp -# Test the dataset search tools and search_issues work # In MCP Inspector: # - Call search_errors(query="errors in production") # - Call search_issues(query="unresolved crashes") ``` -### "Command not found: npx @sentry/mcp-server" +## Troubleshooting -**Cause:** Package not published or not in npm registry. +### "Not authenticated" -**Solution:** - -```bash -# Use local build instead -cd packages/mcp-server -pnpm build -node dist/index.js --access-token=TOKEN -``` +**Cause:** No CLI login and no static token. -### "Missing required parameter: access-token" - -**Cause:** No authentication provided. - -**Solution:** - -```bash -# Option 1: CLI flag -pnpm start --access-token=YOUR_TOKEN - -# Option 2: Environment variable -export SENTRY_ACCESS_TOKEN=YOUR_TOKEN -pnpm start -``` +**Solution:** Run `sentry auth login` (`pnpm --filter sentry run cli auth login` +from source), or pass `--access-token` / `SENTRY_ACCESS_TOKEN`. MCP clients do +not give the server a TTY, so log in from a terminal first. ### "AI-powered search tools unavailable" -**Cause:** Missing `OPENAI_API_KEY`. - -**Solution:** - -```bash -# Set the API key -export OPENAI_API_KEY=your-openai-key -pnpm start --access-token=TOKEN - -# Or disable warning by acknowledging it -# (All other tools will work normally) -``` +**Cause:** Missing `OPENAI_API_KEY`. All other tools work normally. ### "Cannot connect to Sentry API" @@ -551,164 +456,52 @@ pnpm start --access-token=TOKEN 2. Wrong host configuration 3. Network issues -**Solution:** - ```bash # Test token manually -curl -H "Authorization: Bearer YOUR_TOKEN" \ - https://sentry.io/api/0/ - -# For self-hosted, verify host -curl -H "Authorization: Bearer YOUR_TOKEN" \ - https://your-sentry-host.com/api/0/ +curl -H "Authorization: Bearer YOUR_TOKEN" https://sentry.io/api/0/ -# Check logs for detailed error -pnpm start --access-token=TOKEN 2>&1 | tee debug.log +# Check stderr for detailed errors +pnpm --silent --dir packages/cli run cli mcp --access-token=TOKEN 2>debug.log ``` ### "MCP Inspector can't connect to stdio server" -**Causes:** - -1. Incorrect command path -2. Missing dependencies -3. Process exits immediately - -**Solution:** - -```bash -# Test command manually first -pnpm start --access-token=TOKEN - -# If it works, use absolute paths in Inspector -which node # Get absolute path to node -pwd # Get absolute path to project - -# Use absolute paths in Inspector config -{ - "command": "/usr/local/bin/node", - "args": ["/absolute/path/to/sentry-mcp/packages/mcp-server/dist/index.js"] -} -``` - -### "Permission denied" errors - -**Cause:** Built executable not marked as executable. - -**Solution:** - -```bash -chmod +x packages/mcp-server/dist/index.js -``` - -### "Module not found" errors after build - -**Cause:** Missing dependencies in built output. - -**Solution:** - -```bash -# Clean and rebuild -cd packages/mcp-server -rm -rf dist -pnpm build - -# Verify dependencies are bundled -ls -lh dist/ -``` +Run the command manually first. If it works, use absolute paths in the +Inspector config, and keep `--silent` so pnpm prints nothing on stdout. ## Quality Checks Before committing changes that affect stdio: ```bash -# 1. Build successfully pnpm -w run build - -# 2. Type check pnpm -w run tsc - -# 3. Lint pnpm -w run lint - -# 4. Unit tests pass -cd packages/mcp-server -pnpm test - -# 5. Smoke test stdio works +pnpm --dir packages/cli exec vitest run test/lib/mcp test/lib/mcp-start.test.ts echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \ - node dist/index.js --access-token=TOKEN + pnpm --silent --dir packages/cli run cli mcp --access-token=TOKEN ``` ## Comparing with Remote -To verify stdio behaves the same as remote: - -**Test with both transports:** - ```bash # 1. Test stdio locally -pnpm start --access-token=TOKEN -# Use MCP Inspector to test tools +pnpm -w run cli --transport stdio "who am I" # 2. Test remote pnpm -w run cli --mcp-host=https://mcp.sentry.dev "who am I" - -# 3. Compare results -# Both should return same data, same tool list ``` **Key differences to expect:** -- **Authentication:** Stdio uses access tokens, remote uses OAuth +- **Authentication:** Stdio uses CLI credentials or a static token, remote uses OAuth - **Constraints:** Remote supports URL-based org/project constraints - **Tools:** Both should have same tool count and functionality -- **Performance:** Stdio has no network overhead (faster) - -## Advanced Testing - -### Testing Custom Builds - -```bash -# Pack the local build -cd packages/mcp-server -pnpm pack - -# Install globally for testing -npm install -g ./sentry-mcp-server-*.tgz - -# Test as end-user would -sentry-mcp --access-token=TOKEN -``` - -### Testing with Different Node Versions - -```bash -# Using nvm -nvm install 20 -nvm use 20 -pnpm start --access-token=TOKEN - -nvm install 22 -nvm use 22 -pnpm start --access-token=TOKEN -``` - -### Performance Testing - -```bash -# Time tool execution -time node dist/index.js --access-token=TOKEN <&2 && dotenv -e .env -e .env.local -- pnpm --silent --dir packages/cli run cli mcp", "build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build", "build:cli": "dotenv -e .env -e .env.local -- pnpm --filter sentry run build && pnpm --filter sentry-cli-docs run build", "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md", @@ -33,17 +33,18 @@ "eval": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --filter @sentry/mcp-server-evals run eval", "eval:ci": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run eval:ci", "format": "oxfmt --write", - "lint": "oxlint --report-unused-disable-directives . && pnpm run lint:format && pnpm run lint:ast-grep", + "lint": "oxlint --report-unused-disable-directives . && pnpm run lint:format && pnpm run lint:ast-grep && pnpm run lint:deps", "lint:ast-grep": "ast-grep scan && ast-grep test --skip-snapshot-tests", + "lint:deps": "node scripts/check-workspace-deps.mjs", "lint:format": "oxfmt --check", "lint:fix": "oxlint --fix .", "pre-commit:generated": "node scripts/pre-commit-generated.mjs", "inspector": "pnpx @modelcontextprotocol/inspector@latest", - "inspector:stdio": "pnpx @modelcontextprotocol/inspector@latest -- tsx packages/mcp-server/src/index.ts", + "inspector:stdio": "pnpx @modelcontextprotocol/inspector@latest -- pnpm --silent --dir packages/cli run cli mcp", "measure-tokens": "pnpm run --filter ./packages/mcp-core measure-tokens", "prepare": "simple-git-hooks", "cli": "pnpm run --filter ./packages/mcp-test-client start", - "start:stdio": "pnpm --stream run --filter ./packages/mcp-server start", + "start:stdio": "pnpm --silent --dir packages/cli run cli mcp", "test": "pnpm run build && dotenv -e .env -e .env.local -- pnpm -r --filter '!@sentry/mcp-smoke-tests' --if-present run test", "test:ci": "pnpm run build && CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run test:ci", "test:watch": "dotenv -e .env -e .env.local -- pnpm -r --parallel --if-present run test:watch", diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 24595dcd5..cd2319c21 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -113,6 +113,10 @@ Top-level layout: - **`src/bin.ts`** — entry point; **`src/app.ts`** — Stricli application setup; **`src/context.ts`** — dependency-injection context. +- **`src/mcp-server.ts`** — entry point for the standalone `@sentry/mcp-server` + bundle (`script/bundle-mcp-server.ts`). It must not import anything that + only the CLI needs; the bundle script fails on commands, markdown rendering, + agent skills, completions, terminal images, and the SQLite WASM fallback. - **`src/commands/`** — one directory per command group (`agent-conversation`, `alert`, `auth`, `build`, `cli`, `code-mappings`, `dart-symbol-map`, `dashboard`, `debug-files`, `docs`, `dsn`, `event`, `feedback`, `integration`, `issue`, `local`, diff --git a/packages/cli/package.json b/packages/cli/package.json index d5f2cc2a2..15bbc3c18 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,10 +58,11 @@ "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", - "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", + "build": "pnpm --filter @sentry/mcp-core 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 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 run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", + "bundle:mcp-server": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle-mcp-server.ts", + "typecheck": "pnpm --filter @sentry/mcp-core 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", @@ -103,7 +104,7 @@ "@mastra/client-js": "^1.38.0", "@sentry/api": "catalog:", "@sentry/core": "catalog:", - "@sentry/mcp-server": "workspace:*", + "@sentry/mcp-core": "workspace:*", "@sentry/node": "catalog:", "@sentry/search-syntax": "catalog:", "@sentry/sqlish": "^1.0.1", diff --git a/packages/cli/script/bundle-mcp-server.ts b/packages/cli/script/bundle-mcp-server.ts new file mode 100644 index 000000000..1f3e4e42c --- /dev/null +++ b/packages/cli/script/bundle-mcp-server.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env tsx +/** + * Bundle `src/mcp-server.ts` into the standalone `@sentry/mcp-server` package. + * + * The package runs the same server as `sentry mcp` but ships only what the + * server needs: the MCP tools, the API client, and the CLI's auth. The build + * fails if anything on {@link FORBIDDEN_INPUTS} reaches the bundle or the + * output grows past {@link MAX_BUNDLE_BYTES}, so CLI-only code cannot creep in + * through a new import. + */ +import { mkdir, readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build, type Metafile, type Plugin } from "esbuild"; +import { requireAliasPlugin } from "./lib/require-alias-plugin.js"; +import { textImportPlugin } from "./text-import-plugin.js"; + +const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PACKAGE_ROOT = resolve(CLI_ROOT, "../mcp-server"); +const OUTFILE = resolve(PACKAGE_ROOT, "dist/sentry-mcp.cjs"); + +/** + * Current output is about 3.6 MB. The budget leaves room for new tools without + * letting a heavy CLI dependency slip in unnoticed. + */ +const MAX_BUNDLE_BYTES = 4.5 * 1024 * 1024; + +/** + * The login flow's only markdown use is two inline hints, so the bundle swaps + * the renderer for a plain-text version. esbuild fails the build if the swap + * lacks an export that some importer needs. + */ +const MARKDOWN_MODULE = resolve(CLI_ROOT, "src/lib/formatters/markdown.ts"); +const PLAIN_MARKDOWN_MODULE = resolve( + CLI_ROOT, + "src/lib/formatters/markdown-plain.ts", +); + +/** + * Inputs that serve the CLI but not the MCP server: CLI commands, markdown + * rendering, agent skills, shell completions, terminal images, the Ink UI, + * `sentry init`, build/sourcemap/debug-file processing, and the SQLite WASM + * fallback for Node.js < 22.15. + */ +const FORBIDDEN_INPUTS: ReadonlyArray = [ + /[\\/]packages[\\/]cli[\\/]src[\\/](?:app\.ts|commands[\\/])/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:agent-skills|banner|complete|completion-target|completions|kitty-image|sixel|sixel-image)\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/]formatters[\\/](?:markdown|pixel-canvas|sixel-dashboard|sixel-timeseries)\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:bash-hook|build|dif|init|react-native|snapshots|sourcemap|wasm)[\\/]/, + /[\\/]node_modules[\\/](?:cli-highlight|highlight\.js|marked|parse5|ink|ink-spinner|react|react-reconciler|yoga-layout|@sentry[\\/]symbolic|node-sqlite3-wasm)[\\/]/, +]; + +/** The bundle requires native `node:sqlite`, which Node.js 22.15 unflagged. */ +const NODE_VERSION_CHECK = `{let v=process.versions.node.split(".").map(Number);if(v[0]<22||(v[0]===22&&v[1]<15)){console.error("Error: @sentry/mcp-server requires Node.js 22.15 or later (found "+process.version+").");process.exit(1)}}`; + +const plainMarkdownPlugin: Plugin = { + name: "plain-markdown", + setup(b) { + b.onResolve({ filter: /[\\/]markdown\.js$/ }, (args) => + resolve(args.resolveDir, args.path.replace(/\.js$/, ".ts")) === + MARKDOWN_MODULE + ? { path: PLAIN_MARKDOWN_MODULE } + : undefined, + ); + }, +}; + +/** Inputs on {@link FORBIDDEN_INPUTS} that contribute code to the output. */ +function findForbiddenInputs(metafile: Metafile): string[] { + return Object.values(metafile.outputs) + .flatMap((output) => Object.entries(output.inputs)) + .filter(([, { bytesInOutput }]) => bytesInOutput > 0) + .map(([input]) => resolve(CLI_ROOT, input)) + .filter((input) => FORBIDDEN_INPUTS.some((pattern) => pattern.test(input))); +} + +const pkg = JSON.parse( + await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8"), +) as { version: string }; +const cliPkg = JSON.parse( + await readFile(resolve(CLI_ROOT, "package.json"), "utf-8"), +) as { version: string }; + +console.log(`\nBundling @sentry/mcp-server v${pkg.version}`); + +await mkdir(dirname(OUTFILE), { recursive: true }); +const result = await build({ + absWorkingDir: CLI_ROOT, + entryPoints: ["./src/mcp-server.ts"], + outfile: OUTFILE, + bundle: true, + minify: true, + treeShaking: true, + platform: "node", + target: "node22", + format: "cjs", + banner: { js: `#!/usr/bin/env node\n${NODE_VERSION_CHECK}` }, + inject: ["./script/import-meta-url.js"], + define: { + SENTRY_CLI_VERSION: JSON.stringify(cliPkg.version), + SENTRY_MCP_SERVER_VERSION: JSON.stringify(pkg.version), + // Without it, the CLI falls back to its committed default client ID. + ...(process.env.SENTRY_CLIENT_ID + ? { SENTRY_CLIENT_ID_BUILD: JSON.stringify(process.env.SENTRY_CLIENT_ID) } + : {}), + "process.env.NODE_ENV": JSON.stringify("production"), + // Tree-shakes the SDK's runtime module hooks, which can't instrument a + // bundle. + __SENTRY_CHANNEL_INJECTION__: "false", + "import.meta.url": "import_meta_url", + }, + external: [ + "node:*", + // Only reachable on Node.js < 22.15, which the version check rejects. + "node-sqlite3-wasm", + "@sentry/symbolic/symbolic_bg.wasm", + "ink", + "ink-spinner", + "react", + "react/*", + "react-reconciler", + "react-reconciler/*", + "react-devtools-core", + "yoga-layout", + ], + metafile: true, + plugins: [plainMarkdownPlugin, textImportPlugin, requireAliasPlugin], +}); + +const forbidden = findForbiddenInputs(result.metafile); +if (forbidden.length > 0) { + console.error( + "\nError: CLI-only modules reached the @sentry/mcp-server bundle:", + ); + for (const input of forbidden) { + console.error(` ${input}`); + } + console.error("Keep these imports out of the MCP server's import graph.\n"); + process.exit(1); +} + +const bytes = Object.values(result.metafile.outputs).reduce( + (total, output) => total + output.bytes, + 0, +); +if (bytes > MAX_BUNDLE_BYTES) { + console.error( + `\nError: the @sentry/mcp-server bundle is ${(bytes / 1024 / 1024).toFixed(2)} MB, over the ${(MAX_BUNDLE_BYTES / 1024 / 1024).toFixed(1)} MB budget.\n`, + ); + process.exit(1); +} + +console.log( + ` -> ${OUTFILE.slice(PACKAGE_ROOT.length + 1)} (${(bytes / 1024).toFixed(1)} KB)`, +); diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index d6624cfca..94ac1c412 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -7,6 +7,7 @@ import pkg from "../package.json"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; import { buildCoreDeclarations, SDK_TYPES_PATH } from "./sdk-declarations.js"; +import { requireAliasPlugin } from "./lib/require-alias-plugin.js"; import { textImportPlugin } from "./text-import-plugin.js"; const VERSION = pkg.version; @@ -158,39 +159,12 @@ const sentrySourcemapPlugin: Plugin = { }; // Always inject debug IDs (even without auth token); upload is gated inside the plugin -/** - * Files that use `_require()` for lazy imports. The `require-alias` plugin - * rewrites `_require(` → `require(` in these so esbuild resolves them at - * bundle time. `db/sqlite` is included so the WASM SQLite fallback - * (`node-sqlite3-wasm`) is actually inlined into the npm bundle — otherwise - * it stays a runtime `require()` that fails in a real install (the driver is - * a devDependency, not shipped). `node:sqlite` in the same file stays a - * builtin and is left external by esbuild regardless. - * `formatters/json` loads its generated parser only when --fields is parsed. - */ -const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/]json|list-command|telemetry)\.ts$/; -const REQUIRE_ALIAS_RE = /\b_require\(/g; const IDENTIFIER_FILTER_RE = /^[A-Za-z_$][\w$]*$/; // Match both the prefixed and bare specifiers — bundled dependencies (e.g. // binpatch) import from "zlib", which esbuild also treats as external builtin. const NODE_ZLIB_RE = /^(?:node:)?zlib$/; const ANY_RE = /.*/; -/** Transform _require() → require() so esbuild resolves lazy relative requires. */ -const requireAliasPlugin: Plugin = { - name: "require-alias", - setup(b) { - b.onLoad({ filter: REQUIRE_ALIAS_FILTER }, async (args) => { - const source = await readFile(args.path, "utf-8"); - return { - contents: source.replace(REQUIRE_ALIAS_RE, "require("), - loader: args.path.endsWith(".tsx") ? "tsx" : "ts", - }; - }); - }, -}; - /** * ESM-only: make `node:zlib` imports resolve through a namespace shim so that * named imports of version-gated exports (the `zstd*` family, added in Node diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 29adccd49..089ac825a 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -39,7 +39,9 @@ const DOCS_OUTPUTS = [ function run(script) { console.error(`Missing generated files; running \`pnpm run ${script}\`...`); - execSync(`pnpm run ${script}`, { stdio: "inherit" }); + // Send generator output to stderr: `pnpm run cli mcp` serves JSON-RPC on + // stdout, so nothing else may write there. + execSync(`pnpm run ${script}`, { stdio: ["inherit", 2, "inherit"] }); } if (!SCHEMA_OUTPUTS.every(existsSync)) { diff --git a/packages/cli/script/lib/require-alias-plugin.ts b/packages/cli/script/lib/require-alias-plugin.ts new file mode 100644 index 000000000..6b6f927f7 --- /dev/null +++ b/packages/cli/script/lib/require-alias-plugin.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import type { Plugin } from "esbuild"; + +/** + * Files that use `_require()` for lazy imports. The `require-alias` plugin + * rewrites `_require(` → `require(` in these so esbuild resolves them at + * bundle time. `db/sqlite` is included so the WASM SQLite fallback + * (`node-sqlite3-wasm`) is actually inlined into the npm bundle — otherwise + * it stays a runtime `require()` that fails in a real install (the driver is + * a devDependency, not shipped). `node:sqlite` in the same file stays a + * builtin and is left external by esbuild regardless. + * `formatters/json` loads its generated parser only when --fields is parsed. + */ +const REQUIRE_ALIAS_FILTER = + /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/]json|list-command|telemetry)\.ts$/; +const REQUIRE_ALIAS_RE = /\b_require\(/g; + +/** Transform _require() → require() so esbuild resolves lazy relative requires. */ +export const requireAliasPlugin: Plugin = { + name: "require-alias", + setup(b) { + b.onLoad({ filter: REQUIRE_ALIAS_FILTER }, async (args) => { + const source = await readFile(args.path, "utf-8"); + return { + contents: source.replace(REQUIRE_ALIAS_RE, "require("), + loader: args.path.endsWith(".tsx") ? "tsx" : "ts", + }; + }); + }, +}; diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index e7821f6ec..d5100c159 100755 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -3,26 +3,14 @@ * * Stream error handlers are registered here (not in cli.ts) because they're * CLI-specific — the library uses captured Writers that don't have real streams. + * See `lib/stream-errors.ts`. */ -// Suppress "ExperimentalWarning: SQLite is an experimental feature" from -// node:sqlite. Must run before any import triggers the warning. -const _origEmit = process.emit.bind(process) as typeof process.emit; -process.emit = ((event: string, ...args: unknown[]) => { - if ( - event === "warning" && - args[0] instanceof Error && - args[0].name === "ExperimentalWarning" && - args[0].message.includes("SQLite") - ) { - return false; - } - // @ts-expect-error: forwarding args to original emit - return _origEmit(event, ...args); -}) as typeof process.emit; - +// Must stay the first import: later imports can load node:sqlite. +import "./lib/suppress-sqlite-warning.js"; import { startCli } from "./cli.js"; import { wrapCall } from "./lib/react-native/wrap-call.js"; +import { installStreamErrorHandlers } from "./lib/stream-errors.js"; // React Native Xcode build wrapper: when the RN build script invokes us in // place of NODE_BINARY/HERMES_CLI_PATH (see `react-native xcode`), forward to @@ -32,23 +20,7 @@ if (process.env.__SENTRY_RN_WRAP_XCODE_CALL === "1") { process.exit(wrapCall()); } -// Handle non-recoverable stream I/O errors gracefully instead of crashing. -// - EPIPE (errno -32): downstream pipe consumer closed (e.g., `sentry issue list | head`). -// Normal Unix behavior — not an error. Exit 0 because the CLI succeeded. -// - EIO (errno -5): low-level I/O failure on the stream fd. -// Non-recoverable — exit 1 so callers know the output was lost. -function handleStreamError(err: NodeJS.ErrnoException): void { - if (err.code === "EPIPE") { - process.exit(0); - } - if (err.code === "EIO") { - process.exit(1); - } - throw err; -} - -process.stdout.on("error", handleStreamError); -process.stderr.on("error", handleStreamError); +installStreamErrorHandlers(); // startCli handles its own errors — this is a safety net for truly unexpected rejections startCli().catch(() => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ecb59d39e..6e8bba24f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,59 +14,9 @@ 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 { preloadProjectContext } from "./lib/project-context.js"; import { initTimezone } from "./lib/timezone.js"; -/** - * Preload project context: walk up from `cwd` once, finding both the - * project root (for DSN detection) and `.sentryclirc` config (for - * org/project defaults and env shim). Caches both results so later calls - * to `findProjectRoot` and `loadSentryCliRc` are cache hits. - */ -async function preloadProjectContext(cwd: string): Promise { - // Snapshot env-token host BEFORE anything mutates env.SENTRY_HOST/URL - // (the .sentryclirc shim or the default-URL fallback below). Pins the - // env-token's trust scope to the user's shell, not a repo-local file. - // Dynamic import: env-token-host chains into db/auth → telemetry → @sentry/node - const { captureEnvTokenHost } = await import("./lib/env-token-host.js"); - captureEnvTokenHost(); - - // Dynamic import keeps the heavy DSN/DB modules out of the completion fast-path - const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([ - import("./lib/dsn/project-root.js"), - import("./lib/db/project-root-cache.js"), - ]); - - const result = await findProjectRoot(cwd); - await setCachedProjectRoot(cwd, { - projectRoot: result.projectRoot, - reason: result.reason, - }); - - // Apply .sentryclirc env shim (token + URL). The URL trust check is - // deferred to buildCommand's wrapper where commands can opt out via - // skipRcUrlCheck (used by auth login/logout). - // Dynamic import: sentryclirc chains into db/index → sqlite, logger → consola - const { applySentryCliRcEnvShim } = await import("./lib/sentryclirc.js"); - await applySentryCliRcEnvShim(cwd); - - // Apply persistent URL default (lower priority than env vars and .sentryclirc). - const env = getEnv(); - if (!(env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim())) { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - try { - const { getDefaultUrl } = await import("./lib/db/defaults.js"); - const url = getDefaultUrl(); - if (url) { - env.SENTRY_URL = url; - const { markPersistedDefaultUrl } = await import("./lib/constants.js"); - markPersistedDefaultUrl(url); - } - } catch { - // DB not available — skip - } - } -} - /** * Fast-path: shell completion. * @@ -283,28 +233,8 @@ export async function runMcpCommand(cliArgs: string[]): Promise { 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(); - } - + const { runMcpServer } = await import("./lib/mcp/run.js"); + await runMcpServer(mcpArgs); return true; } diff --git a/packages/cli/src/lib/custom-headers.ts b/packages/cli/src/lib/custom-headers.ts index 67a7f592f..1d73316c5 100644 --- a/packages/cli/src/lib/custom-headers.ts +++ b/packages/cli/src/lib/custom-headers.ts @@ -36,6 +36,7 @@ import { getActiveTokenHost, isRequestOriginTrustedForCustomHeaders, } from "./token-host.js"; +import { URL_LOGIN_COMMAND } from "./login-command.js"; const log = logger.withTag("custom-headers"); @@ -352,7 +353,7 @@ export function applyCustomHeaders( untrustedDestinationWarningLogged = true; log.warn( "Skipping custom headers for request to untrusted host. " + - "If this is legitimate, run 'sentry auth login --url ' against the intended instance.", + `If this is legitimate, run '${URL_LOGIN_COMMAND} ' against the intended instance.`, ); } return; diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index a41d54c6a..290ac85a8 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -68,6 +68,7 @@ import { getDatabase } from "./index.js"; import { clearCompletionCache } from "./completion-cache.js"; import { clearAllIssueOrgCache } from "./issue-org-cache.js"; import { clearTrustedHostState } from "./regions.js"; +import { LOGIN_COMMAND } from "../login-command.js"; /** Refresh when less than 10% of token lifetime remains */ export const REFRESH_THRESHOLD = 0.1; @@ -1728,7 +1729,7 @@ export async function refreshToken( } throw new AuthError( "expired", - "Session expired and no refresh token available. Run 'sentry auth login'.", + `Session expired and no refresh token available. Run '${LOGIN_COMMAND}'.`, ); } diff --git a/packages/cli/src/lib/db/migration.ts b/packages/cli/src/lib/db/migration.ts index d57778222..88a90e366 100644 --- a/packages/cli/src/lib/db/migration.ts +++ b/packages/cli/src/lib/db/migration.ts @@ -14,6 +14,7 @@ import { MalformedAuthTokenError } from "../errors.js"; import { logger } from "../logger.js"; import { getConfigDir } from "./index.js"; import type { Database } from "./sqlite.js"; +import { LOGIN_COMMAND } from "../login-command.js"; const log = logger.withTag("migration"); @@ -305,7 +306,7 @@ export function migrateFromJson(db: Database): void { if (invalidAuthToken) { log.warn( "Malformed authentication credentials were not migrated. The original config.json was kept. " + - "Run 'sentry auth login' to authenticate again, then remove the old file.", + `Run '${LOGIN_COMMAND}' to authenticate again, then remove the old file.`, ); } else { // Best-effort cleanup: SQLite metadata prevents re-import if it fails. diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index a2462aa99..a22e19583 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -66,6 +66,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ selfHosted: true, devGuide: "Force env token to take priority over stored OAuth token", }, + { + name: "SENTRY_ACCESS_TOKEN", + description: + "Static auth token for `sentry mcp` and `@sentry/mcp-server` (the env form of the `--access-token` flag). It replaces the CLI session for the MCP server only and is never refreshed. Other commands ignore it.", + example: "sntryu_YOUR_TOKEN_HERE", + }, // -- Targeting -- { name: "SENTRY_ORG", diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index d20a84e1c..a36ac7eee 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -30,6 +30,7 @@ import { buildOrgSettingsUrl, buildSeerSettingsUrl, } from "./sentry-urls.js"; +import { LOGIN_COMMAND, URL_LOGIN_COMMAND } from "./login-command.js"; // --------------------------------------------------------------------------- // Exit code constants @@ -152,14 +153,14 @@ export class HostScopeError extends CliError { super( `${sourceOrMessage}: ${destinationUrl}\n` + "Refusing to route requests to this host because no Sentry credentials are configured for it.\n" + - `To use this host, run: sentry auth login --url ${destinationUrl}`, + `To use this host, run: ${URL_LOGIN_COMMAND} ${destinationUrl}`, EXIT.AUTH_HOST_SCOPE, ); } else { super( `${sourceOrMessage}: ${destinationUrl}\n` + `Refusing to route requests here because it doesn't match the host your Sentry credentials are for (${tokenHost}).\n` + - `To use this host, run: sentry auth login --url ${destinationUrl}\n` + + `To use this host, run: ${URL_LOGIN_COMMAND} ${destinationUrl}\n` + "To keep using your current credentials, remove this URL override.", EXIT.AUTH_HOST_SCOPE, ); @@ -272,9 +273,8 @@ export class AuthError extends CliError { options?: AuthErrorOptions, ) { const defaultMessages: Record = { - not_authenticated: "Not authenticated. Run 'sentry auth login' first.", - expired: - "Authentication expired. Run 'sentry auth login' to re-authenticate.", + not_authenticated: `Not authenticated. Run '${LOGIN_COMMAND}' first.`, + expired: `Authentication expired. Run '${LOGIN_COMMAND}' to re-authenticate.`, invalid: "Invalid authentication token.", }; const exitCodes: Record = { @@ -295,7 +295,7 @@ export class MalformedAuthTokenError extends AuthError { super( "invalid", "Invalid authentication token. Copy it again as a single line without spaces or control characters, " + - "or run 'sentry auth login' to replace stored credentials.", + `or run '${LOGIN_COMMAND}' to replace stored credentials.`, ); this.name = "MalformedAuthTokenError"; } diff --git a/packages/cli/src/lib/formatters/markdown-plain.ts b/packages/cli/src/lib/formatters/markdown-plain.ts new file mode 100644 index 000000000..9d4dfcd53 --- /dev/null +++ b/packages/cli/src/lib/formatters/markdown-plain.ts @@ -0,0 +1,10 @@ +/** + * Plain-text stand-in for `markdown.ts` in the standalone `@sentry/mcp-server` + * bundle. The markdown renderer and its syntax highlighter would add over a + * megabyte for the two inline hints the login flow prints. + */ + +/** Strip inline markdown emphasis and code markers. */ +export function renderInlineMarkdown(md: string): string { + return (md ?? "").replace(/[`*]/g, ""); +} diff --git a/packages/cli/src/lib/login-command.ts b/packages/cli/src/lib/login-command.ts new file mode 100644 index 000000000..d1b149b29 --- /dev/null +++ b/packages/cli/src/lib/login-command.ts @@ -0,0 +1,28 @@ +/** + * The sign-in commands that auth hints name. The standalone + * `@sentry/mcp-server` bundle has its own `auth login`, so its users are not + * sent to a CLI they may not have installed. + */ + +/** Set by `script/bundle-mcp-server.ts` to the `@sentry/mcp-server` version. */ +declare const SENTRY_MCP_SERVER_VERSION: string | undefined; + +/** The `@sentry/mcp-server` version when running as that bundle. */ +export const MCP_SERVER_VERSION = + typeof SENTRY_MCP_SERVER_VERSION === "undefined" + ? undefined + : SENTRY_MCP_SERVER_VERSION; + +/** Command that signs in to the active Sentry host. */ +export const LOGIN_COMMAND = MCP_SERVER_VERSION + ? "npx -y @sentry/mcp-server auth login" + : "sentry auth login"; + +/** + * Command that signs in and confirms a self-hosted host. The standalone + * `auth login` takes no `--url`, so the bundle points at the CLI, which + * shares the session. + */ +export const URL_LOGIN_COMMAND = MCP_SERVER_VERSION + ? "npx -y sentry auth login --url" + : "sentry auth login --url"; diff --git a/packages/cli/src/lib/login-host-guard.ts b/packages/cli/src/lib/login-host-guard.ts index 0ee77f688..052f3e21e 100644 --- a/packages/cli/src/lib/login-host-guard.ts +++ b/packages/cli/src/lib/login-host-guard.ts @@ -21,6 +21,7 @@ import { getDefaultUrl } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { normalizeUserInputToOrigin } from "./sentry-urls.js"; import { isHostTrusted, isLoginTrustAnchorFor } from "./token-host.js"; +import { URL_LOGIN_COMMAND } from "./login-command.js"; /** * Resolve the host a login without `--url` would target, using the same env @@ -91,6 +92,6 @@ export function buildHostRefusalMessage( return ( `Refusing to log in against ${host} — ${sourceClause}.\n\n` + "To authenticate against this self-hosted instance, confirm the host explicitly:\n" + - ` sentry auth login --url ${host}${tokenFlag}` + ` ${URL_LOGIN_COMMAND} ${host}${tokenFlag}` ); } diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index e5b8711e8..c0fe19972 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -1,11 +1,18 @@ import { normalizeAuthToken } from "./auth-header.js"; import { getConfiguredSentryUrl } from "./constants.js"; -import { refreshToken } from "./db/auth.js"; +import { getAccountScope } from "./db/account-scope.js"; +import { accountIdentity, getAccountRow } from "./db/accounts.js"; +import { + getEffectiveAccountRow, + type RefreshTokenResult, + refreshToken, +} from "./db/auth.js"; import { getEnv } from "./env.js"; -import { HostScopeError, ValidationError } from "./errors.js"; +import { AuthError, HostScopeError, ValidationError } from "./errors.js"; +import { parseSntrysClaim } from "./token-claims.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; -type McpServerConfig = { +type McpTarget = { sentryHost: string; sentryProtocol: "http" | "https"; }; @@ -51,19 +58,96 @@ export function prepareMcpServerArgs( } } +/** + * The CLI credential a server process started with. Every later refresh stays + * on it, so another process running `sentry auth switch` or logging in to a + * different account cannot change who the running server acts as. + */ +export type McpCredentialPin = + | { kind: "stored"; accountId: string; userId: string | null } + | { kind: "env" }; + +export type McpCliCredential = { token: string; pin: McpCredentialPin }; + +const ACCOUNT_CHANGED_MESSAGE = + "The account this MCP server started with is no longer available. Restart the MCP server to use the current CLI login."; + +const ENV_PIN: McpCredentialPin = { kind: "env" }; + +/** + * 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 + * the same server apart. + */ +function applyCredentialPin(pin: McpCredentialPin): void { + const scope = getAccountScope(); + if (pin.kind === "env") { + scope.accountId = undefined; + scope.envToken = true; + 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)) { + throw new AuthError("expired", ACCOUNT_CHANGED_MESSAGE, { + skipAutoAuth: true, + }); + } + scope.envToken = undefined; + scope.accountId = pin.accountId; +} + +/** + * The stored account to start with: the effective one, chosen before the + * refresh reads it so the startup token and the pin come from the same row. + */ +function startupPin(): McpCredentialPin | undefined { + const row = getEffectiveAccountRow(); + return row?.token + ? { kind: "stored", accountId: row.account_id, userId: row.user_id } + : 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. + */ +function isPinnedCredential( + result: RefreshTokenResult, + pin: McpCredentialPin, +): boolean { + if (pin.kind === "env") { + return result.source !== "oauth"; + } + if (result.source !== "oauth") { + return false; + } + // SQLite yields null, not undefined, for a missing row. + const row = getAccountRow(pin.accountId); + return row ? accountIdentity(row) === result.identity : false; +} + /** * 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. + * Without `pin`, resolves the effective CLI account and pins it. With `pin`, + * refreshes only that credential. 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 { +export async function resolveCliMcpCredential( + config: McpTarget, + options: { force?: boolean; pin?: McpCredentialPin } = {}, +): Promise { const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`; - const { token } = await refreshToken({ force: options.force }); + const requested = options.pin ?? startupPin(); + if (requested) { + applyCredentialPin(requested); + } + const result = await refreshToken({ force: options.force }); const tokenHost = getActiveTokenHost(); if (!(tokenHost && isHostTrusted(targetUrl, tokenHost))) { @@ -74,10 +158,23 @@ export async function resolveCliMcpAccessToken( ); } + // At startup, an unusable stored credential falls back to the env token, + // the same as other CLI commands. Later, that fallback is a different + // identity, so it is refused like any other account change. + const pin = + !options.pin && result.source !== "oauth" + ? ENV_PIN + : (requested ?? ENV_PIN); + if (!isPinnedCredential(result, pin)) { + throw new AuthError("expired", ACCOUNT_CHANGED_MESSAGE, { + skipAutoAuth: true, + }); + } + // 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); + return { token: normalizeAuthToken(result.token), pin }; } /** @@ -87,14 +184,14 @@ export async function resolveCliMcpAccessToken( * 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. + * it rethrows the original {@link AuthError} so the client sees which login + * command to run instead of a hung browser prompt. */ -async function resolveMcpAccessTokenWithLogin( - config: McpServerConfig, -): Promise { +async function resolveMcpCredentialWithLogin( + config: McpTarget, +): Promise { try { - return await resolveCliMcpAccessToken(config); + return await resolveCliMcpCredential(config); } catch (error) { const { isatty } = await import("node:tty"); const { shouldAutoAuth, assertAutoLoginHostTrusted } = @@ -119,47 +216,83 @@ async function resolveMcpAccessTokenWithLogin( throw error; } - return await resolveCliMcpAccessToken(config); + return await resolveCliMcpCredential(config); + } +} + +/** + * Validate a static token from `--access-token` / `SENTRY_ACCESS_TOKEN`. + * + * The token is used as given and never refreshed. An org auth token + * (`sntrys_`) records the server it was issued for; refuse to send it to any + * other host, the same way the CLI scopes its own env tokens. + */ +export function resolveStaticMcpAccessToken( + config: McpTarget & { accessToken: string }, +): string { + const token = normalizeAuthToken(config.accessToken); + const claimedHost = parseSntrysClaim(token)?.url; + const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`; + if (claimedHost && !isHostTrusted(targetUrl, claimedHost)) { + throw new HostScopeError( + "Cannot start MCP server with the provided access token", + targetUrl, + claimedHost, + ); } + return token; } -/** Start the local stdio server without introducing a second auth flow. */ -export async function startMcpServer(args: string[]): Promise { +/** + * Start the local stdio server without introducing a second auth flow. + * + * Resolves `true` once the server is running, or `false` when the invocation + * only printed output (`--help`, `--version`) and the process can exit. + */ +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, + const env = getEnv(); + const { parseMcpInvocation, serveMcpStdio } = await import("./mcp/server.js"); + const invocation = parseMcpInvocation(prepareMcpServerArgs(args), env); + if (invocation.kind === "output") { + process.stdout.write(`${invocation.text}\n`); + return false; + } + + // A repo-local `.sentryclirc` can set the target URL, but it is not a trust + // source: without this gate, a static token (which carries no host claim + // unless it is an org token) 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. + if (!hasSentryTargetArg(args)) { + const { assertRcUrlTrusted } = await import("./sentryclirc.js"); + await assertRcUrlTrusted(process.cwd()); + } + + const { config } = invocation; + const { accessToken } = config; + if (accessToken) { + serveMcpStdio(config, { + accessToken: resolveStaticMcpAccessToken({ ...config, accessToken }), + env, + }); + return true; + } + + const { token, pin } = await resolveMcpCredentialWithLogin(config); + serveMcpStdio(config, { + accessToken: token, + // Force a refresh past the usual threshold: the upstream already rejected + // the current token, so mirror `--follow`'s refresh-on-401 behavior. The + // pin keeps the refresh on the startup account. + refreshAccessToken: async () => + (await resolveCliMcpCredential(config, { force: true, pin })).token, + env, }); + return true; } diff --git a/packages/cli/src/lib/mcp/args.ts b/packages/cli/src/lib/mcp/args.ts new file mode 100644 index 000000000..2349fcfe7 --- /dev/null +++ b/packages/cli/src/lib/mcp/args.ts @@ -0,0 +1,184 @@ +/** + * Argument and environment parsing for `sentry mcp` and `@sentry/mcp-server`. + * + * Both accept the flags and environment variables that `@sentry/mcp-server` + * has always taken, so existing MCP client configurations keep working. Flags win + * over environment values. + */ + +import { parseArgs } from "node:util"; + +/** Values read from `sentry mcp` flags. */ +export type McpCliArgs = { + accessToken?: string; + host?: string; + insecureHttp?: boolean; + url?: string; + mcpUrl?: string; + sentryDsn?: string; + openaiBaseUrl?: string; + openaiModel?: string; + anthropicBaseUrl?: string; + anthropicModel?: string; + agentProvider?: string; + skills?: string; + allSkills?: boolean; + disableSkills?: string; + experimental?: boolean; + organizationSlug?: string; + projectSlug?: string; + help?: boolean; + version?: boolean; + unknownArgs: string[]; +}; + +/** Values read from the environment. */ +export type McpEnvArgs = { + accessToken?: string; + mcpUrl?: string; + sentryDsn?: string; + openaiModel?: string; + anthropicModel?: string; + agentProvider?: string; + skills?: string; + disableSkills?: string; +}; + +/** Flags merged over environment values, before validation. */ +export type McpMergedArgs = Omit & { + /** Whether `--agent-provider` (not the environment) chose the provider. */ + agentProviderFromFlag: boolean; +}; + +const OPTIONS = { + "access-token": { type: "string" }, + host: { type: "string" }, + "insecure-http": { type: "boolean" }, + url: { type: "string" }, + "mcp-url": { type: "string" }, + "sentry-dsn": { type: "string" }, + "openai-base-url": { type: "string" }, + "openai-model": { type: "string" }, + "anthropic-base-url": { type: "string" }, + "anthropic-model": { type: "string" }, + "agent-provider": { type: "string" }, + "organization-slug": { type: "string" }, + "project-slug": { type: "string" }, + skills: { type: "string" }, + "all-skills": { type: "boolean" }, + "disable-skills": { type: "string" }, + experimental: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, +} as const; + +/** `parseArgs` reports known short flags under their long name. */ +const KNOWN_OPTIONS = new Set(Object.keys(OPTIONS)); + +function stringValue(value: string | boolean | undefined): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** Parse `sentry mcp` flags, collecting unknown flags and positionals. */ +export function parseMcpArgv(argv: string[]): McpCliArgs { + const { values, tokens } = parseArgs({ + args: argv, + options: OPTIONS, + allowPositionals: true, + strict: false, + tokens: true, + }); + + const unknownArgs: string[] = []; + for (const token of tokens) { + if (token.kind === "option" && !KNOWN_OPTIONS.has(token.name)) { + unknownArgs.push(token.rawName); + } else if (token.kind === "positional") { + unknownArgs.push(token.value); + } + } + + return { + accessToken: stringValue(values["access-token"]), + host: stringValue(values.host), + insecureHttp: values["insecure-http"] === true, + url: stringValue(values.url), + mcpUrl: stringValue(values["mcp-url"]), + sentryDsn: stringValue(values["sentry-dsn"]), + openaiBaseUrl: stringValue(values["openai-base-url"]), + openaiModel: stringValue(values["openai-model"]), + anthropicBaseUrl: stringValue(values["anthropic-base-url"]), + anthropicModel: stringValue(values["anthropic-model"]), + agentProvider: stringValue(values["agent-provider"]), + organizationSlug: stringValue(values["organization-slug"]), + projectSlug: stringValue(values["project-slug"]), + skills: stringValue(values.skills), + allSkills: values["all-skills"] === true, + disableSkills: stringValue(values["disable-skills"]), + experimental: values.experimental === true, + help: values.help === true, + version: values.version === true, + unknownArgs, + }; +} + +/** + * Read MCP settings from the environment. + * + * `SENTRY_HOST`/`SENTRY_URL` are not read here: `sentry mcp` translates the + * CLI's configured URL into `--host`/`--url` flags before parsing. + */ +export function parseMcpEnv(env: NodeJS.ProcessEnv): McpEnvArgs { + const fromEnv: McpEnvArgs = {}; + if (env.SENTRY_ACCESS_TOKEN) { + fromEnv.accessToken = env.SENTRY_ACCESS_TOKEN; + } + if (env.MCP_URL) { + fromEnv.mcpUrl = env.MCP_URL; + } + if (env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN) { + fromEnv.sentryDsn = env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN; + } + if (env.OPENAI_MODEL) { + fromEnv.openaiModel = env.OPENAI_MODEL; + } + if (env.ANTHROPIC_MODEL) { + fromEnv.anthropicModel = env.ANTHROPIC_MODEL; + } + if (env.EMBEDDED_AGENT_PROVIDER) { + fromEnv.agentProvider = env.EMBEDDED_AGENT_PROVIDER; + } + if (env.MCP_SKILLS) { + fromEnv.skills = env.MCP_SKILLS; + } + if (env.MCP_DISABLE_SKILLS) { + fromEnv.disableSkills = env.MCP_DISABLE_SKILLS; + } + return fromEnv; +} + +/** Merge flags over environment values. */ +export function mergeMcpArgs(cli: McpCliArgs, env: McpEnvArgs): McpMergedArgs { + return { + accessToken: cli.accessToken ?? env.accessToken, + url: cli.url, + host: cli.host, + insecureHttp: cli.insecureHttp === true, + mcpUrl: cli.mcpUrl ?? env.mcpUrl, + sentryDsn: cli.sentryDsn ?? env.sentryDsn, + openaiBaseUrl: cli.openaiBaseUrl, + openaiModel: cli.openaiModel ?? env.openaiModel, + anthropicBaseUrl: cli.anthropicBaseUrl, + anthropicModel: cli.anthropicModel ?? env.anthropicModel, + agentProvider: cli.agentProvider ?? env.agentProvider, + agentProviderFromFlag: cli.agentProvider !== undefined, + // --skills and --all-skills both override MCP_SKILLS. + skills: cli.skills ?? (cli.allSkills ? undefined : env.skills), + allSkills: cli.allSkills === true, + disableSkills: cli.disableSkills ?? env.disableSkills, + experimental: cli.experimental === true, + organizationSlug: cli.organizationSlug, + projectSlug: cli.projectSlug, + unknownArgs: cli.unknownArgs, + }; +} diff --git a/packages/cli/src/lib/mcp/auth.ts b/packages/cli/src/lib/mcp/auth.ts new file mode 100644 index 000000000..59f02e10e --- /dev/null +++ b/packages/cli/src/lib/mcp/auth.ts @@ -0,0 +1,34 @@ +/** + * `sentry-mcp auth` for the standalone `@sentry/mcp-server` bundle. + * + * Only `login` is built in, so the package can authenticate without the CLI. + * Account management stays in `sentry auth`, which uses the same session. + */ + +import { assertAutoLoginHostTrusted } from "../auto-auth.js"; +import { ValidationError } from "../errors.js"; +import { runInteractiveLogin } from "../interactive-login.js"; + +/** Run `sentry-mcp auth [login]`. */ +export async function runMcpServerAuth(args: readonly string[]): Promise { + const [subcommand = "login", ...rest] = args; + if (subcommand !== "login" || rest.length > 0) { + throw new ValidationError( + `\`sentry-mcp auth ${args.join(" ")}\` is not supported. ` + + `Run \`npx -y sentry auth ${args.join(" ")}\` instead; it uses the same session.`, + ); + } + + // Never start an OAuth device flow against an unconfirmed self-hosted host. + assertAutoLoginHostTrusted(); + + const result = await runInteractiveLogin(); + if (!result) { + process.exitCode = 1; + return; + } + const identity = result.user?.email ?? result.user?.username; + process.stderr.write( + identity ? `Logged in as ${identity}.\n` : "Logged in.\n", + ); +} diff --git a/packages/cli/src/lib/mcp/config.ts b/packages/cli/src/lib/mcp/config.ts new file mode 100644 index 000000000..245bd0761 --- /dev/null +++ b/packages/cli/src/lib/mcp/config.ts @@ -0,0 +1,209 @@ +/** + * Validation and defaults for `sentry mcp` settings. + * + * Turns merged flags and environment values into the server configuration: + * the Sentry target, granted skills, and embedded agent provider settings. + * The credential is resolved later, once the target host is known. + */ + +import { + ACTIVE_SKILLS, + ALL_SKILLS, + parseSkills, + type Skill, +} from "@sentry/mcp-core/skills"; +import { + validateAndParseSentryUrlThrows, + validateOpenAiBaseUrlThrows, + validateSentryHostThrows, +} from "@sentry/mcp-core/utils/url-utils"; +import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; +import { ValidationError } from "../errors.js"; +import type { McpMergedArgs } from "./args.js"; + +/** Embedded agent providers the MCP server can use. */ +export type McpAgentProvider = + | "openai" + | "azure-openai" + | "anthropic" + | "openrouter"; + +/** Validated `sentry mcp` configuration, before credential resolution. */ +export type McpServerConfig = { + /** Static token from `--access-token` or `SENTRY_ACCESS_TOKEN`. */ + accessToken?: string; + sentryHost: string; + sentryProtocol: "http" | "https"; + mcpUrl?: string; + sentryDsn?: string; + openaiBaseUrl?: string; + openaiModel?: string; + anthropicBaseUrl?: string; + anthropicModel?: string; + agentProvider?: McpAgentProvider; + agentProviderFromFlag: boolean; + experimental: boolean; + /** Skills granted for this session. */ + finalSkills: Set; + organizationSlug?: string; + projectSlug?: string; +}; + +const AGENT_PROVIDERS: ReadonlySet = new Set([ + "openai", + "azure-openai", + "anthropic", + "openrouter", +]); + +function isAgentProvider(value: string): value is McpAgentProvider { + return AGENT_PROVIDERS.has(value); +} + +/** Wrap mcp-core validation errors in the CLI's input validation error. */ +function validated(validate: () => T): T { + try { + return validate(); + } catch (error) { + throw new ValidationError( + error instanceof Error ? error.message : String(error), + ); + } +} + +export function formatInvalidSkills( + invalid: string[], + source?: string, +): string { + const prefix = source ? `${source} provided` : "Invalid skills provided"; + return `${prefix}: ${invalid.join(", ")}\nAvailable skills: ${ALL_SKILLS.join(", ")}`; +} + +/** Resolve the Sentry host from `--url` or `--host`, defaulting to sentry.io. */ +export function resolveHost(url?: string, host?: string): string { + if (url) { + return validated(() => validateAndParseSentryUrlThrows(url)); + } + if (host) { + validated(() => validateSentryHostThrows(host)); + return host; + } + return "sentry.io"; +} + +/** + * Resolve granted skills. + * + * stdio intentionally grants every active skill when `--skills` is absent, so + * a local server works without extra configuration. The hosted OAuth flow + * asks the user to choose instead. Seer is a sentry.io service: self-hosted + * autofix endpoints answer 500 without it, so it is left out of the implicit + * default there; `--skills=seer` or `--all-skills` opt back in. + */ +function resolveSkills(input: McpMergedArgs, sentryHost: string): Set { + if (input.skills && input.allSkills) { + throw new ValidationError("--all-skills cannot be combined with --skills."); + } + + let finalSkills: Set; + if (input.allSkills) { + finalSkills = new Set(ACTIVE_SKILLS); + } else if (input.skills) { + const { valid, invalid } = parseSkills(input.skills); + if (invalid.length > 0) { + throw new ValidationError(formatInvalidSkills(invalid)); + } + if (valid.size === 0) { + throw new ValidationError( + "Invalid skills provided. No valid skills found.", + ); + } + finalSkills = valid; + } else { + finalSkills = new Set(ACTIVE_SKILLS); + if (!isSentryHost(sentryHost)) { + finalSkills.delete("seer"); + } + } + + if (input.disableSkills) { + const { valid: skillsToDisable, invalid } = parseSkills( + input.disableSkills, + ); + if (invalid.length > 0) { + throw new ValidationError( + formatInvalidSkills(invalid, "--disable-skills"), + ); + } + for (const skill of skillsToDisable) { + finalSkills.delete(skill); + } + if (finalSkills.size === 0) { + throw new ValidationError( + "All skills have been disabled. At least one skill must remain enabled.", + ); + } + } + + return finalSkills; +} + +function resolveAgentProvider(value?: string): McpAgentProvider | undefined { + if (!value) { + return; + } + const provider = value.toLowerCase(); + if (!isAgentProvider(provider)) { + throw new ValidationError( + `Invalid agent provider "${value}". Must be "openai", "azure-openai", "anthropic", or "openrouter".`, + ); + } + return provider; +} + +/** Validate merged settings and apply defaults. */ +export function finalizeMcpConfig(input: McpMergedArgs): McpServerConfig { + if (input.unknownArgs.length > 0) { + throw new ValidationError( + `Invalid argument(s): ${input.unknownArgs.join(", ")}\nRun \`sentry mcp --help\` for usage.`, + ); + } + if (input.insecureHttp && input.url) { + throw new ValidationError( + "--insecure-http cannot be used with --url. Use --host for insecure self-hosted instances.", + ); + } + + const sentryHost = resolveHost(input.url, input.host); + if (input.insecureHttp && isSentryHost(sentryHost)) { + throw new ValidationError( + "--insecure-http is only supported for self-hosted Sentry hosts.", + ); + } + const finalSkills = resolveSkills(input, sentryHost); + + return { + accessToken: input.accessToken, + sentryHost, + sentryProtocol: input.insecureHttp ? "http" : "https", + mcpUrl: input.mcpUrl, + sentryDsn: input.sentryDsn, + // Anthropic base URLs follow the same rules as OpenAI ones. + openaiBaseUrl: input.openaiBaseUrl + ? validated(() => validateOpenAiBaseUrlThrows(input.openaiBaseUrl ?? "")) + : undefined, + openaiModel: input.openaiModel, + anthropicBaseUrl: input.anthropicBaseUrl + ? validated(() => + validateOpenAiBaseUrlThrows(input.anthropicBaseUrl ?? ""), + ) + : undefined, + anthropicModel: input.anthropicModel, + agentProvider: resolveAgentProvider(input.agentProvider), + agentProviderFromFlag: input.agentProviderFromFlag, + experimental: input.experimental === true, + finalSkills, + organizationSlug: input.organizationSlug, + projectSlug: input.projectSlug, + }; +} diff --git a/packages/cli/src/lib/mcp/run.ts b/packages/cli/src/lib/mcp/run.ts new file mode 100644 index 000000000..88432d705 --- /dev/null +++ b/packages/cli/src/lib/mcp/run.ts @@ -0,0 +1,37 @@ +/** + * Run the stdio MCP server and handle setup failures. + * + * Shared by `sentry mcp` and the standalone `@sentry/mcp-server` bundle. + */ + +import { formatError, getExitCode } from "../errors.js"; +import { startMcpServer } from "../mcp.js"; + +/** + * Release network resources after a finite MCP invocation, mirroring the + * teardown `runCli` applies to every other drained command. Never call this + * while a stdio server is running: it would close the server's dispatcher and + * arm a force-exit on macOS. + */ +export async function releaseProcessResources(): Promise { + const [{ scheduleForceExit }, { closeGlobalDispatcher }] = await Promise.all([ + import("../force-exit.js"), + import("../close-dispatcher.js"), + ]); + scheduleForceExit(); + await closeGlobalDispatcher(); +} + +export async function runMcpServer(args: string[]): Promise { + try { + if (await startMcpServer(args)) { + return; + } + } catch (mcpError) { + process.stderr.write(`Error: ${formatError(mcpError)}\n`); + process.exitCode = getExitCode(mcpError); + } + // Setup errors and `--help`/`--version` are terminal, unlike a running + // stdio server. + await releaseProcessResources(); +} diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts new file mode 100644 index 000000000..e1345f497 --- /dev/null +++ b/packages/cli/src/lib/mcp/server.ts @@ -0,0 +1,371 @@ +/** + * stdio MCP server host for `sentry mcp`. + * + * Parses the server flags, configures the embedded agent provider and MCP + * telemetry, then serves `@sentry/mcp-core` over stdin/stdout. Credentials are + * resolved by the caller (`lib/mcp.ts`) so this module never owns an auth flow. + * + * Loaded lazily: it pulls in mcp-core (and Zod), which normal CLI commands + * must not pay for at startup. + */ + +import { + getAgentProvider, + getResolvedProviderType, + setAgentProvider, + setProviderBaseUrls, +} from "@sentry/mcp-core/internal/agents/provider-factory"; +import { buildServer } from "@sentry/mcp-core/server"; +import { ALL_SKILLS } from "@sentry/mcp-core/skills"; +import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; +import { startStdio } from "@sentry/mcp-core/transports/stdio"; +import type { ServerContext } from "@sentry/mcp-core/types"; +import { LIB_VERSION } from "@sentry/mcp-core/version"; +import { + captureException, + consoleLoggingIntegration, + flush, + init, + vercelAIIntegration, + zodErrorsIntegration, +} from "@sentry/node"; +import { mergeMcpArgs, parseMcpArgv, parseMcpEnv } from "./args.js"; +import { finalizeMcpConfig, type McpServerConfig } from "./config.js"; +import { buildMcpUsage, buildMcpVersion } from "./usage.js"; + +/** What a `sentry mcp` invocation asks for. */ +export type McpInvocation = + | { kind: "output"; text: string } + | { kind: "serve"; config: McpServerConfig }; + +/** Credential wiring supplied by the CLI host. */ +export type McpServeOptions = { + accessToken: string; + /** + * Fetch a fresh credential after an upstream 401. Omitted for static + * tokens, which are never refreshed. + */ + refreshAccessToken?: () => Promise; + env: NodeJS.ProcessEnv; +}; + +const SENTRY_FLUSH_TIMEOUT_MS = 5000; + +/** Parse `sentry mcp` arguments into help/version output or a server config. */ +export function parseMcpInvocation( + args: string[], + env: NodeJS.ProcessEnv, +): McpInvocation { + const cli = parseMcpArgv(args); + if (cli.help) { + return { kind: "output", text: buildMcpUsage(ALL_SKILLS) }; + } + if (cli.version) { + return { kind: "output", text: buildMcpVersion() }; + } + return { + kind: "serve", + config: finalizeMcpConfig(mergeMcpArgs(cli, parseMcpEnv(env))), + }; +} + +type ProviderMismatch = { configured: string; availableKey: string }; + +/** The configured provider's key is missing while another provider's is set. */ +function findProviderMismatch( + configured: string | undefined, + env: NodeJS.ProcessEnv, +): ProviderMismatch | undefined { + const keys = { + openai: Boolean(env.OPENAI_API_KEY), + anthropic: Boolean(env.ANTHROPIC_API_KEY), + openrouter: Boolean(env.OPENROUTER_API_KEY), + }; + if (configured === "openai" || configured === "azure-openai") { + if (!keys.openai && (keys.anthropic || keys.openrouter)) { + return { + configured, + availableKey: keys.openrouter + ? "OPENROUTER_API_KEY" + : "ANTHROPIC_API_KEY", + }; + } + } else if (configured === "anthropic") { + if (!keys.anthropic && (keys.openai || keys.openrouter)) { + return { + configured, + availableKey: keys.openrouter ? "OPENROUTER_API_KEY" : "OPENAI_API_KEY", + }; + } + } else if ( + configured === "openrouter" && + !keys.openrouter && + (keys.openai || keys.anthropic) + ) { + return { + configured, + availableKey: keys.openai ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY", + }; + } + return; +} + +function expectedProviderKey(provider: string): string { + if (provider === "openai" || provider === "azure-openai") { + return "OPENAI_API_KEY"; + } + return provider === "openrouter" ? "OPENROUTER_API_KEY" : "ANTHROPIC_API_KEY"; +} + +/** Explain why AI-powered search tools are unavailable. */ +function warnNoProvider(config: McpServerConfig, env: NodeJS.ProcessEnv): void { + const configured = + config.agentProvider ?? env.EMBEDDED_AGENT_PROVIDER?.toLowerCase(); + const providerKeyCount = [ + env.ANTHROPIC_API_KEY, + env.OPENAI_API_KEY, + env.OPENROUTER_API_KEY, + ].filter(Boolean).length; + const mismatch = findProviderMismatch(configured, env); + + let providerConfigError: string | undefined; + try { + getAgentProvider(); + } catch (error) { + providerConfigError = + error instanceof Error ? error.message : String(error); + } + + if (providerKeyCount > 1 && !configured) { + console.warn( + "Warning: Multiple LLM API keys are set, but no provider is explicitly configured.", + ); + console.warn( + "Please set EMBEDDED_AGENT_PROVIDER='openai', 'azure-openai', 'anthropic', or 'openrouter' to specify which provider to use.", + ); + console.warn( + "AI-powered search tools will be unavailable until a provider is selected.", + ); + } else if (mismatch) { + const expectedKey = expectedProviderKey(mismatch.configured); + const providerSetting = config.agentProviderFromFlag + ? `--agent-provider=${mismatch.configured}` + : `EMBEDDED_AGENT_PROVIDER='${mismatch.configured}'`; + const changeProviderHint = config.agentProviderFromFlag + ? "Change --agent-provider to match your available API key" + : "Change EMBEDDED_AGENT_PROVIDER to match your available API key"; + console.warn(`Warning: ${providerSetting} but ${expectedKey} is not set.`); + console.warn(`Found ${mismatch.availableKey} instead. Either:`); + console.warn( + ` - Set ${expectedKey} to use the ${mismatch.configured} provider, or`, + ); + console.warn(` - ${changeProviderHint}`); + console.warn( + "AI-powered search tools will be unavailable until this is resolved.", + ); + } else if ( + providerConfigError && + !providerConfigError.startsWith("No embedded agent provider configured") + ) { + console.warn(`Warning: ${providerConfigError}`); + console.warn( + "AI-powered search tools will be unavailable until this is resolved.", + ); + } else { + console.warn( + "Warning: No LLM API key found (OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY).", + ); + console.warn("AI-powered search tools will be unavailable."); + console.warn( + "Search tools still work with direct Sentry query syntax via the 'query' parameter.", + ); + } + console.warn(""); +} + +/** Configure the embedded agent provider and report which one is active. */ +function configureAgentProvider( + config: McpServerConfig, + env: NodeJS.ProcessEnv, +): void { + if (config.agentProvider) { + setAgentProvider(config.agentProvider); + } + setProviderBaseUrls({ + openaiBaseUrl: config.openaiBaseUrl, + anthropicBaseUrl: config.anthropicBaseUrl, + }); + // The provider factory reads model overrides from the environment. + if (config.openaiModel) { + env.OPENAI_MODEL = config.openaiModel; + } + if (config.anthropicModel) { + env.ANTHROPIC_MODEL = config.anthropicModel; + } + + const resolvedProvider = getResolvedProviderType(); + if (!resolvedProvider) { + warnNoProvider(config, env); + return; + } + + let providerSource = "auto-detected"; + if (config.agentProviderFromFlag) { + providerSource = "explicitly configured"; + } else if (env.EMBEDDED_AGENT_PROVIDER) { + providerSource = "from EMBEDDED_AGENT_PROVIDER"; + } + console.warn( + `Using ${getAgentProvider().label} for AI-powered search tools (${providerSource}).`, + ); + if (providerSource === "auto-detected") { + console.warn( + "Deprecation warning: Auto-detection of LLM provider is deprecated.", + ); + console.warn( + `Please set EMBEDDED_AGENT_PROVIDER='${resolvedProvider}' explicitly.`, + ); + console.warn("Auto-detection will be removed in a future release."); + } + console.warn(""); +} + +function initMcpTelemetry( + config: McpServerConfig, + env: NodeJS.ProcessEnv, +): void { + const scopeContext = { + "app.server.version": LIB_VERSION, + "app.transport": "stdio", + "app.upstream.host": config.sentryHost, + "app.url.full": config.mcpUrl, + }; + + init({ + dsn: config.sentryDsn, + tracesSampleRate: 1, + beforeSend: sentryBeforeSend, + initialScope: { + tags: { + ...scopeContext, + "app.server.mode.experimental": config.experimental ? "true" : "false", + }, + // SDK v11 does not copy scope tags onto streamed spans. + attributes: { + ...scopeContext, + "app.server.mode.experimental": config.experimental, + }, + }, + release: env.SENTRY_RELEASE, + integrations: [ + consoleLoggingIntegration(), + zodErrorsIntegration(), + vercelAIIntegration({ recordInputs: true, recordOutputs: true }), + ], + // The retired package fixed this at build time; keep the same value. + environment: "stdio", + }); +} + +/** + * Exit on fatal errors without recursing when stderr is already closed. + * + * @internal Exported for testing + */ +export function installProcessHandlers(): ( + error: unknown, + label: string, +) => void { + let exiting = false; + function exitAfterError(error: unknown, label: string): void { + if (exiting) { + process.exit(1); + } + exiting = true; + + // If the client closed every pipe, console.error can throw EPIPE inside + // this handler and recurse forever (getsentry/sentry-mcp#1274). + // oxlint-disable-next-line sentry-cli/no-silent-catch -- stderr may already be closed; exiting is the only recovery. + try { + console.error(label, error); + } catch { + // stderr may already be closed + } + // oxlint-disable-next-line sentry-cli/no-silent-catch -- reporting must not block exit. + try { + captureException(error); + } catch { + // reporting must not block exit + } + + // Don't depend on flush finishing — exit even if it hangs or rejects. + setTimeout(() => process.exit(1), SENTRY_FLUSH_TIMEOUT_MS).unref(); + void flush(SENTRY_FLUSH_TIMEOUT_MS).finally(() => process.exit(1)); + } + + async function shutdown(signal: string): Promise { + // A fatal error is already flushing and will exit 1; a signal in that + // window must not turn the crash into a successful exit. + if (exiting) { + return; + } + exiting = true; + console.error(`${signal} received, shutting down...`); + await flush(SENTRY_FLUSH_TIMEOUT_MS); + process.exit(0); + } + + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("uncaughtException", (error) => { + exitAfterError(error, "Uncaught exception:"); + }); + process.on("unhandledRejection", (reason) => { + exitAfterError(reason, "Unhandled rejection:"); + }); + return exitAfterError; +} + +/** Serve the MCP tools over stdio until the client disconnects. */ +export function serveMcpStdio( + config: McpServerConfig, + options: McpServeOptions, +): void { + configureAgentProvider(config, options.env); + initMcpTelemetry(config, options.env); + + if (config.experimental) { + console.warn( + "Experimental mode enabled: Forward-looking tool variants and experimental features are available.", + ); + console.warn(""); + } + + const exitAfterError = installProcessHandlers(); + const context: ServerContext = { + accessToken: options.accessToken, + grantedSkills: config.finalSkills, + constraints: { + organizationSlug: config.organizationSlug ?? null, + projectSlug: config.projectSlug ?? null, + regionUrl: null, + }, + sentryHost: config.sentryHost, + sentryProtocol: config.sentryProtocol, + mcpUrl: config.mcpUrl, + openaiBaseUrl: config.openaiBaseUrl, + experimentalMode: config.experimental, + transport: "stdio", + // mcp-core uses the returned token for later tool calls, so a + // long-running server recovers from an expired CLI session. + onUpstreamUnauthorized: options.refreshAccessToken, + }; + + const server = buildServer({ + context, + experimentalMode: config.experimental, + }); + startStdio(server, context).catch((error: unknown) => { + exitAfterError(error, "Server error:"); + }); +} diff --git a/packages/cli/src/lib/mcp/usage.ts b/packages/cli/src/lib/mcp/usage.ts new file mode 100644 index 000000000..4bf9afe46 --- /dev/null +++ b/packages/cli/src/lib/mcp/usage.ts @@ -0,0 +1,77 @@ +/** Help and version text for `sentry mcp` and `sentry-mcp`. */ + +import type { Skill } from "@sentry/mcp-core/skills"; +import { CLI_VERSION } from "../constants.js"; +import { LOGIN_COMMAND, MCP_SERVER_VERSION } from "../login-command.js"; + +const COMMAND = MCP_SERVER_VERSION ? "sentry-mcp" : "sentry mcp"; + +/** Build the `--version` output. */ +export function buildMcpVersion(): string { + return `${COMMAND} ${MCP_SERVER_VERSION ?? CLI_VERSION}`; +} + +/** Build the `--help` text. */ +export function buildMcpUsage(allSkills: ReadonlyArray): string { + return `Usage: ${COMMAND} [--access-token=] [--host=] [--insecure-http] + +Authentication: + Uses the active Sentry CLI session by default. Authenticate with \`${LOGIN_COMMAND}\`. + --access-token Use this Sentry auth token instead of the CLI session + (never refreshed; org auth tokens must match the selected host) + +Common optional flags: + --host Change Sentry host (self-hosted) + --url Change Sentry URL (https only; use --host for http) + --insecure-http Use http:// for self-hosted --host values + --mcp-url MCP server URL reported in telemetry + --sentry-dsn Override DSN used for telemetry reporting + --experimental Enable forward-looking tool variants and experimental features + -h, --help Show this help + -v, --version Show the version + +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: + SENTRY_ACCESS_TOKEN Static Sentry token (alternative to --access-token) + SENTRY_HOST, SENTRY_URL Sentry instance (same as for other sentry commands) + MCP_URL MCP server URL reported in telemetry + SENTRY_DSN DSN used for telemetry reporting + OPENAI_API_KEY OpenAI API key for AI-powered search tools + OPENAI_MODEL OpenAI model override + ANTHROPIC_API_KEY Anthropic API key for AI-powered search tools + ANTHROPIC_MODEL Anthropic model override + 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: + ${COMMAND} + ${COMMAND} --all-skills + ${COMMAND} --skills=inspect,triage + ${COMMAND} --host=sentry.example.com + ${COMMAND} --host=sentry.internal:9000 --insecure-http + ${COMMAND} --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer + ${COMMAND} --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ + ${COMMAND} --agent-provider=anthropic`; +} diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 4fa143a3e..3c06f8da4 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -38,6 +38,7 @@ import { import { logger } from "./logger.js"; import { normalizeHttpOrigin } from "./sentry-urls.js"; import { getActiveTokenHost } from "./token-host.js"; +import { LOGIN_COMMAND } from "./login-command.js"; /** * Get the Sentry instance URL for OAuth endpoints. @@ -395,7 +396,7 @@ async function attemptPoll( case "expired": return { status: "error", - message: "Device code expired. Please run 'sentry auth login' again.", + message: `Device code expired. Please run '${LOGIN_COMMAND}' again.`, }; case "denied": return { @@ -574,7 +575,7 @@ export async function refreshAccessToken( if (rejected) { throw new AuthError( "expired", - "Session expired because the refresh credential was rejected. Run 'sentry auth login' to re-authenticate.", + `Session expired because the refresh credential was rejected. Run '${LOGIN_COMMAND}' to re-authenticate.`, ); } throw new ApiError( diff --git a/packages/cli/src/lib/project-context.ts b/packages/cli/src/lib/project-context.ts new file mode 100644 index 000000000..4bb08ec24 --- /dev/null +++ b/packages/cli/src/lib/project-context.ts @@ -0,0 +1,56 @@ +/** + * Project context loading shared by every entry point that runs CLI code. + */ + +import { getEnv } from "./env.js"; + +/** + * Preload project context: walk up from `cwd` once, finding both the + * project root (for DSN detection) and `.sentryclirc` config (for + * org/project defaults and env shim). Caches both results so later calls + * to `findProjectRoot` and `loadSentryCliRc` are cache hits. + */ +export async function preloadProjectContext(cwd: string): Promise { + // Snapshot env-token host BEFORE anything mutates env.SENTRY_HOST/URL + // (the .sentryclirc shim or the default-URL fallback below). Pins the + // env-token's trust scope to the user's shell, not a repo-local file. + // Dynamic import: env-token-host chains into db/auth → telemetry → @sentry/node + const { captureEnvTokenHost } = await import("./env-token-host.js"); + captureEnvTokenHost(); + + // Dynamic import keeps the heavy DSN/DB modules out of the completion fast-path + const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([ + import("./dsn/project-root.js"), + import("./db/project-root-cache.js"), + ]); + + const result = await findProjectRoot(cwd); + await setCachedProjectRoot(cwd, { + projectRoot: result.projectRoot, + reason: result.reason, + }); + + // Apply .sentryclirc env shim (token + URL). The URL trust check is + // deferred to buildCommand's wrapper where commands can opt out via + // skipRcUrlCheck (used by auth login/logout). + // Dynamic import: sentryclirc chains into db/index → sqlite, logger → consola + const { applySentryCliRcEnvShim } = await import("./sentryclirc.js"); + await applySentryCliRcEnvShim(cwd); + + // Apply persistent URL default (lower priority than env vars and .sentryclirc). + const env = getEnv(); + if (!(env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim())) { + // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. + try { + const { getDefaultUrl } = await import("./db/defaults.js"); + const url = getDefaultUrl(); + if (url) { + env.SENTRY_URL = url; + const { markPersistedDefaultUrl } = await import("./constants.js"); + markPersistedDefaultUrl(url); + } + } catch { + // DB not available — skip + } + } +} diff --git a/packages/cli/src/lib/stream-errors.ts b/packages/cli/src/lib/stream-errors.ts new file mode 100644 index 000000000..05d2da8a4 --- /dev/null +++ b/packages/cli/src/lib/stream-errors.ts @@ -0,0 +1,28 @@ +/** + * Stream error handlers for process entry points (`bin.ts`, `mcp-server.ts`). + * + * The library uses captured Writers without real streams, so it never + * installs these. + */ + +// Handle non-recoverable stream I/O errors gracefully instead of crashing. +// - EPIPE (errno -32): downstream pipe consumer closed (e.g., `sentry issue list | head`, +// or an MCP client closing its pipes). Normal Unix behavior — not an error. +// Exit 0 because the process succeeded. +// - EIO (errno -5): low-level I/O failure on the stream fd. +// Non-recoverable — exit 1 so callers know the output was lost. +function handleStreamError(err: NodeJS.ErrnoException): void { + if (err.code === "EPIPE") { + process.exit(0); + } + if (err.code === "EIO") { + process.exit(1); + } + throw err; +} + +/** Register {@link handleStreamError} on stdout and stderr. */ +export function installStreamErrorHandlers(): void { + process.stdout.on("error", handleStreamError); + process.stderr.on("error", handleStreamError); +} diff --git a/packages/cli/src/lib/suppress-sqlite-warning.ts b/packages/cli/src/lib/suppress-sqlite-warning.ts new file mode 100644 index 000000000..4182ac319 --- /dev/null +++ b/packages/cli/src/lib/suppress-sqlite-warning.ts @@ -0,0 +1,18 @@ +/** + * Drop node:sqlite's "SQLite is an experimental feature" warning. Import this + * module first in each entry point, before anything loads node:sqlite. + */ + +const originalEmit = process.emit.bind(process) as typeof process.emit; +process.emit = ((event: string, ...args: unknown[]) => { + if ( + event === "warning" && + args[0] instanceof Error && + args[0].name === "ExperimentalWarning" && + args[0].message.includes("SQLite") + ) { + return false; + } + // @ts-expect-error: forwarding args to original emit + return originalEmit(event, ...args); +}) as typeof process.emit; diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts new file mode 100644 index 000000000..fc48c475c --- /dev/null +++ b/packages/cli/src/mcp-server.ts @@ -0,0 +1,56 @@ +/** + * Entry point for the standalone `@sentry/mcp-server` bundle. + * + * Runs the same server as `sentry mcp` without the rest of the CLI, so the + * package stays small. Built by `script/bundle-mcp-server.ts`. + */ + +// Must stay the first import: later imports can load node:sqlite, whose +// warning MCP clients surface as a server error. +import "./lib/suppress-sqlite-warning.js"; +import { redactCredentialText } from "./lib/credential-redaction.js"; +import { CliError, formatError, getExitCode } from "./lib/errors.js"; +import { preloadProjectContext } from "./lib/project-context.js"; +import { installStreamErrorHandlers } from "./lib/stream-errors.js"; + +// Same as `bin.ts`: an MCP client closing its pipes is a normal disconnect, +// not a crash, including during setup before the server's own handlers exist. +installStreamErrorHandlers(); + +async function main(args: string[]): Promise { + try { + await preloadProjectContext(process.cwd()); + } catch (err) { + if (err instanceof CliError) { + process.stderr.write(`${formatError(err)}\n`); + process.exitCode = err.exitCode; + return; + } + // Project context is optional; a missing or unreadable rc file is fine. + } + + if (args[0] === "auth") { + const [{ runMcpServerAuth }, { releaseProcessResources }] = + await Promise.all([ + import("./lib/mcp/auth.js"), + import("./lib/mcp/run.js"), + ]); + try { + await runMcpServerAuth(args.slice(1)); + } catch (err) { + process.stderr.write(`Error: ${formatError(err)}\n`); + process.exitCode = getExitCode(err); + } finally { + await releaseProcessResources(); + } + return; + } + + const { runMcpServer } = await import("./lib/mcp/run.js"); + await runMcpServer(args); +} + +main(process.argv.slice(2)).catch((err) => { + process.stderr.write(`Fatal: ${redactCredentialText(String(err))}\n`); + process.exitCode = 1; +}); diff --git a/packages/cli/test/lib/formatters/markdown-plain.test.ts b/packages/cli/test/lib/formatters/markdown-plain.test.ts new file mode 100644 index 000000000..33e0299d8 --- /dev/null +++ b/packages/cli/test/lib/formatters/markdown-plain.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { renderInlineMarkdown } from "../../../src/lib/formatters/markdown-plain.js"; + +describe("renderInlineMarkdown (plain)", () => { + test("drops code and emphasis markers", () => { + expect(renderInlineMarkdown(" Code: `ABCD-EFGH`")).toBe( + " Code: ABCD-EFGH", + ); + expect(renderInlineMarkdown(" *(`c` to copy URL)*")).toBe( + " (c to copy URL)", + ); + }); +}); diff --git a/packages/cli/test/lib/mcp-auth.test.ts b/packages/cli/test/lib/mcp-auth.test.ts index e0388a2d4..1566a5db7 100644 --- a/packages/cli/test/lib/mcp-auth.test.ts +++ b/packages/cli/test/lib/mcp-auth.test.ts @@ -7,7 +7,7 @@ import { } from "../../src/lib/errors.js"; import { prepareMcpServerArgs, - resolveCliMcpAccessToken, + resolveCliMcpCredential, startMcpServer, } from "../../src/lib/mcp.js"; import { @@ -33,7 +33,13 @@ afterEach(async () => { await resetHostScopingState(); }); -describe("resolveCliMcpAccessToken", () => { +async function resolveCliMcpAccessToken( + config: Parameters[0], +): Promise { + return (await resolveCliMcpCredential(config)).token; +} + +describe("resolveCliMcpCredential", () => { test("returns the active CLI session token for its scoped host", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", diff --git a/packages/cli/test/lib/mcp-run.test.ts b/packages/cli/test/lib/mcp-run.test.ts index aefe31ff8..77e48ba18 100644 --- a/packages/cli/test/lib/mcp-run.test.ts +++ b/packages/cli/test/lib/mcp-run.test.ts @@ -36,7 +36,7 @@ describe("runMcpCommand", () => { }); test("keeps a running server alive on success", async () => { - startMcpServer.mockResolvedValue(undefined); + startMcpServer.mockResolvedValue(true); const { runMcpCommand } = await import("../../src/cli.js"); await expect(runMcpCommand(["mcp"])).resolves.toBe(true); @@ -48,6 +48,18 @@ describe("runMcpCommand", () => { expect(closeGlobalDispatcher).not.toHaveBeenCalled(); }); + test("cleans up after an invocation that only prints output", async () => { + startMcpServer.mockResolvedValue(false); + const { runMcpCommand } = await import("../../src/cli.js"); + + await expect(runMcpCommand(["mcp", "--help"])).resolves.toBe(true); + + // `--help` and `--version` finish like any other drained command, so they + // need the same teardown `runCli` gives every other command. + expect(scheduleForceExit).toHaveBeenCalledTimes(1); + expect(closeGlobalDispatcher).toHaveBeenCalledTimes(1); + }); + test("cleans up network resources when setup fails", async () => { startMcpServer.mockRejectedValue(new Error("boom")); const { runMcpCommand } = await import("../../src/cli.js"); diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 79af97885..24694c838 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -1,28 +1,69 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { setAuthToken } from "../../src/lib/db/auth.js"; import { + clearAuth, + removeAccount, + setActiveAccount, + setAuthToken, +} from "../../src/lib/db/auth.js"; +import { setUserInfo } from "../../src/lib/db/user.js"; +import { + HostScopeError, + MalformedAuthTokenError, +} from "../../src/lib/errors.js"; +import type { McpServerConfig } from "../../src/lib/mcp/config.js"; +import { + applySentryCliRcEnvShim, + clearSentryCliRcCache, +} from "../../src/lib/sentryclirc.js"; +import type { McpServeOptions } from "../../src/lib/mcp/server.js"; +import { + mintSntrysToken, resetHostScopingState, useEnvSandbox, useTestConfigDir, } from "../helpers.js"; -type CapturedOptions = { - resolveAccessToken?: (config: unknown) => Promise; - onUpstreamUnauthorized?: ( - setAccessToken: (token: string) => void, - ) => void | Promise; -}; +type Served = { config: McpServerConfig; options: McpServeOptions }; + +const served: { calls: Served[] } = { calls: [] }; -const captured: { options?: CapturedOptions } = {}; +/** + * Hooks around the CLI's `refreshToken`, to change the credential store at + * the exact moments another process could. + */ +const refreshHooks = vi.hoisted(() => ({ + before: undefined as (() => Promise | void) | undefined, + after: undefined as (() => Promise | void) | undefined, +})); + +vi.mock("../../src/lib/db/auth.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + refreshToken: async (...args: Parameters) => { + await refreshHooks.before?.(); + const result = await actual.refreshToken(...args); + await refreshHooks.after?.(); + return result; + }, + }; +}); -vi.mock("@sentry/mcp-server", () => ({ - runMcpServer: vi.fn(async (_args: string[], options: CapturedOptions) => { - captured.options = options; +vi.mock("../../src/lib/mcp/server.js", async (importOriginal) => ({ + ...(await importOriginal()), + serveMcpStdio: vi.fn((config: McpServerConfig, options: McpServeOptions) => { + served.calls.push({ config, options }); }), })); useTestConfigDir("mcp-start-"); useEnvSandbox([ + "SENTRY_CLIENT_ID", + "SENTRY_ACCESS_TOKEN", "SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_FORCE_ENV_TOKEN", @@ -31,70 +72,360 @@ useEnvSandbox([ ]); beforeEach(async () => { - captured.options = undefined; + served.calls = []; await resetHostScopingState(); }); afterEach(async () => { + vi.restoreAllMocks(); await resetHostScopingState(); }); +async function start(args: string[]): Promise { + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await expect(startMcpServer(args)).resolves.toBe(true); + expect(served.calls).toHaveLength(1); + const [call] = served.calls; + if (!call) { + throw new Error("serveMcpStdio was not called"); + } + return call; +} + describe("startMcpServer credential wiring", () => { - test("resolves the CLI session token for the MCP server", async () => { + test("serves with the CLI session token", 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"); + const { options } = await start([]); + expect(options.accessToken).toBe("cli-session-token"); + expect(options.refreshAccessToken).toBeDefined(); }); - test("refreshes and writes back a new token on upstream 401", async () => { + test("refreshes the CLI session token after an 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", - }); + const { options } = await start([]); // Simulate a refreshed credential landing in the store before the handler runs. setAuthToken("rotated-token", undefined, undefined, { host: "https://sentry.io", }); + await expect(options.refreshAccessToken?.()).resolves.toBe("rotated-token"); + }); +}); - let written: string | undefined; - await captured.options?.onUpstreamUnauthorized?.((token) => { - written = token; +describe("startMcpServer account pinning", () => { + const originalFetch = globalThis.fetch; + const refreshedWith: string[] = []; + + /** Store a new account on sentry.io, as `sentry auth login` does. */ + function login(token: string, userId: string): string { + const id = setAuthToken(token, 3600, `${token}-refresh`, { + host: "https://sentry.io", + newAccount: true, }); + setUserInfo({ userId, email: `${userId}@example.com` }); + return id; + } + + beforeEach(async () => { + await clearAuth(); + refreshedWith.length = 0; + process.env.SENTRY_CLIENT_ID = "test-client-id"; + // Each refresh rotates the access token of whichever refresh token it got. + globalThis.fetch = async (input, init) => { + expect(new Request(input).url).toBe("https://sentry.io/oauth/token/"); + const body = init?.body; + const refresh = + body instanceof URLSearchParams ? body.get("refresh_token") : null; + refreshedWith.push(refresh ?? ""); + return Response.json({ + access_token: `${refresh?.replace(/-refresh$/, "")}-rotated`, + token_type: "bearer", + expires_in: 3600, + refresh_token: refresh, + }); + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + refreshHooks.before = undefined; + refreshHooks.after = undefined; + }); + + test("keeps refreshing the startup account after another process switches accounts", async () => { + const a = login("a-token", "1"); + const b = login("b-token", "2"); + setActiveAccount(a); + const { options } = await start([]); + expect(options.accessToken).toBe("a-token"); + + setActiveAccount(b); + + await expect(options.refreshAccessToken?.()).resolves.toBe( + "a-token-rotated", + ); + // A second refresh, after the first cleared the cached rows, stays on A. + await expect(options.refreshAccessToken?.()).resolves.toBe( + "a-token-rotated", + ); + expect(refreshedWith).toEqual(["a-token-refresh", "a-token-refresh"]); + }); + + test("fails closed when the startup account is removed", async () => { + const a = login("a-token", "1"); + login("b-token", "2"); + setActiveAccount(a); + const { options } = await start([]); + + await removeAccount(a, { promoteNext: true }); + + await expect(options.refreshAccessToken?.()).rejects.toThrow( + "The account this MCP server started with is no longer available", + ); + expect(refreshedWith).toEqual([]); + }); + + test("fails closed when another user logs in over the startup account", async () => { + login("a-token", "1"); + const { options } = await start([]); + + // `sentry auth login --force` replaces the account's credentials in place. + setAuthToken("c-token", 3600, "c-token-refresh", { + host: "https://sentry.io", + }); + setUserInfo({ userId: "3", email: "3@example.com" }); + + await expect(options.refreshAccessToken?.()).rejects.toThrow( + "The account this MCP server started with is no longer available", + ); + expect(refreshedWith).toEqual([]); + }); + + test("pins the account whose token startup returned, even if the active account changes right after", async () => { + const a = login("a-token", "1"); + const b = login("b-token", "2"); + setActiveAccount(a); + refreshHooks.after = () => { + refreshHooks.after = undefined; + setActiveAccount(b); + }; + + const { options } = await start([]); + expect(options.accessToken).toBe("a-token"); + + await expect(options.refreshAccessToken?.()).resolves.toBe( + "a-token-rotated", + ); + expect(refreshedWith).toEqual(["a-token-refresh"]); + }); + + test("refuses another account's token when the startup account vanishes mid-refresh", async () => { + const a = login("a-token", "1"); + login("b-token", "2"); + setActiveAccount(a); + const { options } = await start([]); + + // Removed after the pin check but before the credential read, so the + // CLI's account selection falls back to the remaining account. + refreshHooks.before = () => removeAccount(a, { promoteNext: true }); + + await expect(options.refreshAccessToken?.()).rejects.toThrow( + "The account this MCP server started with is no longer available", + ); + }); + + test("keeps an env token after a stored login appears", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_HOST = "https://sentry.io"; + const { options } = await start([]); + expect(options.accessToken).toBe("env-token"); + + login("stored-token", "1"); + + await expect(options.refreshAccessToken?.()).resolves.toBe("env-token"); + }); +}); + +describe("startMcpServer static tokens", () => { + test("uses --access-token without a CLI session and never refreshes it", async () => { + await clearAuth(); - expect(written).toBe("rotated-token"); + const { config, options } = await start(["--access-token=static-token"]); + expect(options.accessToken).toBe("static-token"); + expect(options.refreshAccessToken).toBeUndefined(); + expect(config.sentryHost).toBe("sentry.io"); }); - test("ignores an upstream 401 before any token has been resolved", async () => { + test("uses SENTRY_ACCESS_TOKEN without a CLI session", async () => { + await clearAuth(); + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + + const { options } = await start([]); + expect(options.accessToken).toBe("env-static-token"); + expect(options.refreshAccessToken).toBeUndefined(); + }); + + test("prefers --access-token over SENTRY_ACCESS_TOKEN", async () => { + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + + const { options } = await start(["--access-token", "flag-token"]); + expect(options.accessToken).toBe("flag-token"); + }); + + test("prefers a static token over the CLI session", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", }); + const { options } = await start(["--access-token=static-token"]); + expect(options.accessToken).toBe("static-token"); + }); + + test("normalizes a padded static token", async () => { + const { options } = await start(["--access-token=static-token\n"]); + expect(options.accessToken).toBe("static-token"); + }); + + test("rejects a malformed static token", async () => { + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await expect( + startMcpServer(["--access-token=has a space"]), + ).rejects.toBeInstanceOf(MalformedAuthTokenError); + expect(served.calls).toHaveLength(0); + }); + + test("serves a static token on the self-hosted host it is configured for", async () => { + const { config, options } = await start([ + "--access-token=static-token", + "--host=sentry.internal:9000", + "--insecure-http", + ]); + expect(options.accessToken).toBe("static-token"); + expect(config.sentryHost).toBe("sentry.internal:9000"); + expect(config.sentryProtocol).toBe("http"); + }); + + test("routes SENTRY_HOST to the static token target", async () => { + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + + const { config } = await start([]); + expect(config.sentryHost).toBe("sentry.example.com"); + }); + + test("allows an org auth token on the host it was issued for", async () => { + const token = mintSntrysToken({ + iat: 1, + url: "https://sentry.example.com", + org: "acme", + }); + + const { options } = await start([ + `--access-token=${token}`, + "--host=sentry.example.com", + ]); + expect(options.accessToken).toBe(token); + }); + + test("refuses to send an org auth token to another host", async () => { + const token = mintSntrysToken({ + iat: 1, + url: "https://sentry.example.com", + org: "acme", + }); + const { startMcpServer } = await import("../../src/lib/mcp.js"); - await startMcpServer([]); + await expect( + startMcpServer([`--access-token=${token}`, "--host=evil.example.com"]), + ).rejects.toBeInstanceOf(HostScopeError); + expect(served.calls).toHaveLength(0); + }); +}); + +describe("startMcpServer .sentryclirc URLs", () => { + /** Run from a directory whose `.sentryclirc` points at `url`. */ + async function inRcDir(url: string): Promise { + const dir = mkdtempSync(join(tmpdir(), "mcp-start-rc-")); + writeFileSync(join(dir, ".sentryclirc"), `[defaults]\nurl = ${url}\n`); + rcDirs.push(dir); + vi.spyOn(process, "cwd").mockReturnValue(dir); + clearSentryCliRcCache(); + await applySentryCliRcEnvShim(dir); + } + + const rcDirs: string[] = []; + + beforeEach(async () => { + await clearAuth(); + }); - let written: string | undefined; - await captured.options?.onUpstreamUnauthorized?.((token) => { - written = token; + afterEach(() => { + clearSentryCliRcCache(); + for (const dir of rcDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("refuses to send a static token to a self-hosted URL from a repo .sentryclirc", async () => { + await inRcDir("https://evil.example.com"); + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await expect(startMcpServer([])).rejects.toBeInstanceOf(HostScopeError); + await expect( + startMcpServer(["--access-token=static-token"]), + ).rejects.toBeInstanceOf(HostScopeError); + expect(served.calls).toHaveLength(0); + }); + + test("serves a static token when an explicit --host overrides the rc URL", async () => { + await inRcDir("https://evil.example.com"); + + const { config } = await start([ + "--access-token=static-token", + "--host=sentry.example.com", + ]); + expect(config.sentryHost).toBe("sentry.example.com"); + }); + + test("serves a static token for a SaaS rc URL", async () => { + await inRcDir("https://sentry.io"); + + const { options } = await start(["--access-token=static-token"]); + expect(options.accessToken).toBe("static-token"); + }); + + test("serves a static token for an rc URL the user has logged in to", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.example.com", }); + await inRcDir("https://sentry.example.com"); - expect(written).toBeUndefined(); + const { config } = await start(["--access-token=static-token"]); + expect(config.sentryHost).toBe("sentry.example.com"); }); }); + +describe("startMcpServer output flags", () => { + test.each([["--help"], ["--version"]])( + "prints %s to stdout without serving", + async (flag) => { + const write = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const { startMcpServer } = await import("../../src/lib/mcp.js"); + + await expect(startMcpServer([flag])).resolves.toBe(false); + + expect(served.calls).toHaveLength(0); + expect(write).toHaveBeenCalledWith( + expect.stringMatching(/^(Usage: sentry mcp|sentry mcp \S+)/), + ); + }, + ); +}); diff --git a/packages/cli/test/lib/mcp/args.test.ts b/packages/cli/test/lib/mcp/args.test.ts new file mode 100644 index 000000000..a6a9823b6 --- /dev/null +++ b/packages/cli/test/lib/mcp/args.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; +import { + mergeMcpArgs, + parseMcpArgv, + parseMcpEnv, +} from "../../../src/lib/mcp/args.js"; + +describe("parseMcpArgv", () => { + it("parses known flags and short aliases", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--host=sentry.io", + "--insecure-http", + "--url=https://example.com", + "--mcp-url=https://mcp.example.com", + "--sentry-dsn=dsn", + "--openai-base-url=https://api.example.com/v1", + "--skills=inspect,triage", + "--all-skills", + "-h", + "-v", + ]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.host).toBe("sentry.io"); + expect(parsed.insecureHttp).toBe(true); + expect(parsed.url).toBe("https://example.com"); + expect(parsed.mcpUrl).toBe("https://mcp.example.com"); + expect(parsed.sentryDsn).toBe("dsn"); + expect(parsed.openaiBaseUrl).toBe("https://api.example.com/v1"); + expect(parsed.skills).toBe("inspect,triage"); + expect(parsed.allSkills).toBe(true); + expect(parsed.help).toBe(true); + expect(parsed.version).toBe(true); + expect(parsed.unknownArgs).toEqual([]); + }); + + it("parses skills flags", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--skills=inspect,triage", + ]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.skills).toBe("inspect,triage"); + }); + + it("parses --disable-skills", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--disable-skills=seer", + ]); + expect(parsed.disableSkills).toBe("seer"); + }); + + it("parses --all-skills", () => { + const parsed = parseMcpArgv(["--access-token=tok", "--all-skills"]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.allSkills).toBe(true); + }); + + it("parses --insecure-http", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--host=sentry.internal:9000", + "--insecure-http", + ]); + expect(parsed.host).toBe("sentry.internal:9000"); + expect(parsed.insecureHttp).toBe(true); + }); + + it("parses --agent-provider=azure-openai", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--agent-provider=azure-openai", + ]); + expect(parsed.agentProvider).toBe("azure-openai"); + }); + + it("parses --agent-provider=openrouter", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--agent-provider=openrouter", + ]); + expect(parsed.agentProvider).toBe("openrouter"); + }); + + it("collects unknown args", () => { + const parsed = parseMcpArgv(["--unknown", "--another=1"]); + expect(parsed.unknownArgs.length).toBeGreaterThan(0); + }); +}); + +describe("parseMcpEnv", () => { + it("parses environment variables including skills", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_URL: "envmcp", + SENTRY_DSN: "envdsn", + MCP_SKILLS: "inspect,triage", + }); + expect(env.accessToken).toBe("envtok"); + expect(env.mcpUrl).toBe("envmcp"); + expect(env.sentryDsn).toBe("envdsn"); + expect(env.skills).toBe("inspect,triage"); + }); + + it("reads MCP_DISABLE_SKILLS", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_DISABLE_SKILLS: "seer", + }); + expect(env.disableSkills).toBe("seer"); + }); +}); + +describe("mergeMcpArgs", () => { + it("applies precedence: CLI over env", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_URL: "envmcp", + SENTRY_DSN: "envdsn", + }); + const cli = parseMcpArgv([ + "--access-token=clitok", + "--host=clihost", + "--insecure-http", + "--mcp-url=climcp", + "--sentry-dsn=clidsn", + "--openai-base-url=https://api.cli/v1", + ]); + const merged = mergeMcpArgs(cli, env); + expect(merged.accessToken).toBe("clitok"); + expect(merged.host).toBe("clihost"); + expect(merged.insecureHttp).toBe(true); + expect(merged.mcpUrl).toBe("climcp"); + expect(merged.sentryDsn).toBe("clidsn"); + expect(merged.openaiBaseUrl).toBe("https://api.cli/v1"); + }); + + it("applies precedence for skills: CLI over env", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect", + }); + const cli = parseMcpArgv([ + "--access-token=clitok", + "--skills=inspect,triage", + ]); + const merged = mergeMcpArgs(cli, env); + expect(merged.skills).toBe("inspect,triage"); + }); + + it("falls back to env when CLI skills not provided", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect,triage", + }); + const cli = parseMcpArgv(["--access-token=clitok"]); + const merged = mergeMcpArgs(cli, env); + expect(merged.skills).toBe("inspect,triage"); + }); + + it("lets CLI allSkills override env skills", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect", + }); + const cli = parseMcpArgv(["--access-token=clitok", "--all-skills"]); + const merged = mergeMcpArgs(cli, env); + expect(merged.allSkills).toBe(true); + expect(merged.skills).toBeUndefined(); + }); + + it("applies precedence for disableSkills: CLI over env", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_DISABLE_SKILLS: "docs", + }); + const cli = parseMcpArgv([ + "--access-token=clitok", + "--disable-skills=seer", + ]); + const merged = mergeMcpArgs(cli, env); + expect(merged.disableSkills).toBe("seer"); + }); + + it("falls back to env when CLI disableSkills not provided", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_DISABLE_SKILLS: "seer", + }); + const cli = parseMcpArgv(["--access-token=clitok"]); + const merged = mergeMcpArgs(cli, env); + expect(merged.disableSkills).toBe("seer"); + }); + + it("applies precedence for agentProvider: CLI over env", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + EMBEDDED_AGENT_PROVIDER: "openai", + }); + const cli = parseMcpArgv([ + "--access-token=clitok", + "--agent-provider=azure-openai", + ]); + const merged = mergeMcpArgs(cli, env); + expect(merged.agentProvider).toBe("azure-openai"); + }); +}); + +describe("@sentry/mcp-server flag parity", () => { + it("accepts every standalone server flag", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--host=sentry.example.com", + "--insecure-http", + "--url=https://sentry.example.com", + "--mcp-url=https://mcp.example.com", + "--sentry-dsn=dsn", + "--openai-base-url=https://openai.example.com/v1", + "--openai-model=gpt-test", + "--anthropic-base-url=https://anthropic.example.com", + "--anthropic-model=claude-test", + "--agent-provider=anthropic", + "--organization-slug=acme", + "--project-slug=web", + "--skills=inspect", + "--all-skills", + "--disable-skills=seer", + "--experimental", + "--help", + "--version", + ]); + expect(parsed).toEqual({ + accessToken: "tok", + host: "sentry.example.com", + insecureHttp: true, + url: "https://sentry.example.com", + mcpUrl: "https://mcp.example.com", + sentryDsn: "dsn", + openaiBaseUrl: "https://openai.example.com/v1", + openaiModel: "gpt-test", + anthropicBaseUrl: "https://anthropic.example.com", + anthropicModel: "claude-test", + agentProvider: "anthropic", + organizationSlug: "acme", + projectSlug: "web", + skills: "inspect", + allSkills: true, + disableSkills: "seer", + experimental: true, + help: true, + version: true, + unknownArgs: [], + }); + }); + + it("accepts space-separated flag values", () => { + const parsed = parseMcpArgv(["--access-token", "tok", "--host", "h.test"]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.host).toBe("h.test"); + }); + + it("reports unknown flags and positionals by their raw spelling", () => { + expect(parseMcpArgv(["--bogus=1", "-x", "extra"]).unknownArgs).toEqual([ + "--bogus", + "-x", + "extra", + ]); + }); + + it("reads every standalone server environment variable", () => { + expect( + parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_URL: "https://mcp.example.com", + SENTRY_DSN: "dsn", + OPENAI_MODEL: "gpt-test", + ANTHROPIC_MODEL: "claude-test", + EMBEDDED_AGENT_PROVIDER: "openrouter", + MCP_SKILLS: "inspect", + MCP_DISABLE_SKILLS: "seer", + }), + ).toEqual({ + accessToken: "envtok", + mcpUrl: "https://mcp.example.com", + sentryDsn: "dsn", + openaiModel: "gpt-test", + anthropicModel: "claude-test", + agentProvider: "openrouter", + skills: "inspect", + disableSkills: "seer", + }); + }); + + it("falls back to DEFAULT_SENTRY_DSN for telemetry", () => { + expect(parseMcpEnv({ DEFAULT_SENTRY_DSN: "default" }).sentryDsn).toBe( + "default", + ); + }); + + it("uses SENTRY_ACCESS_TOKEN when --access-token is absent", () => { + const merged = mergeMcpArgs( + parseMcpArgv([]), + parseMcpEnv({ SENTRY_ACCESS_TOKEN: "envtok" }), + ); + expect(merged.accessToken).toBe("envtok"); + }); + + it("records whether the agent provider came from the flag", () => { + const env = parseMcpEnv({ EMBEDDED_AGENT_PROVIDER: "openai" }); + expect(mergeMcpArgs(parseMcpArgv([]), env).agentProviderFromFlag).toBe( + false, + ); + expect( + mergeMcpArgs(parseMcpArgv(["--agent-provider=openai"]), env) + .agentProviderFromFlag, + ).toBe(true); + }); +}); 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..cb790d2cb --- /dev/null +++ b/packages/cli/test/lib/mcp/auth.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ValidationError } from "../../../src/lib/errors.js"; + +const assertAutoLoginHostTrusted = vi.fn(); +const runInteractiveLogin = vi.fn(); + +vi.mock("../../../src/lib/auto-auth.js", () => ({ + assertAutoLoginHostTrusted: () => assertAutoLoginHostTrusted(), +})); +vi.mock("../../../src/lib/interactive-login.js", () => ({ + runInteractiveLogin: (...args: unknown[]) => runInteractiveLogin(...args), +})); + +const { runMcpServerAuth } = await import("../../../src/lib/mcp/auth.js"); + +const originalExitCode = process.exitCode; +let stderr: string; + +beforeEach(() => { + assertAutoLoginHostTrusted.mockReset(); + runInteractiveLogin.mockReset(); + stderr = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = originalExitCode; +}); + +describe("runMcpServerAuth", () => { + test.each([[[]], [["login"]]])("logs in for %j", async (args) => { + runInteractiveLogin.mockResolvedValue({ + method: "oauth", + configPath: "/tmp/cli.db", + user: { email: "user@example.com" }, + }); + + await runMcpServerAuth(args); + + expect(assertAutoLoginHostTrusted).toHaveBeenCalledTimes(1); + expect(runInteractiveLogin).toHaveBeenCalledTimes(1); + expect(stderr).toBe("Logged in as user@example.com.\n"); + expect(process.exitCode).toBe(originalExitCode); + }); + + test("checks the host before starting the device flow", async () => { + assertAutoLoginHostTrusted.mockImplementation(() => { + throw new ValidationError("untrusted host"); + }); + + await expect(runMcpServerAuth(["login"])).rejects.toThrow("untrusted host"); + expect(runInteractiveLogin).not.toHaveBeenCalled(); + }); + + test("fails when the login does not complete", async () => { + runInteractiveLogin.mockResolvedValue(null); + + await runMcpServerAuth(["login"]); + + expect(process.exitCode).toBe(1); + }); + + test.each([[["status"]], [["logout"]], [["login", "--url=x"]]])( + "points %j to the CLI", + async (args) => { + await expect(runMcpServerAuth(args)).rejects.toThrow( + `npx -y sentry auth ${args.join(" ")}`, + ); + expect(runInteractiveLogin).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/mcp-server/src/cli/resolve.test.ts b/packages/cli/test/lib/mcp/config.test.ts similarity index 85% rename from packages/mcp-server/src/cli/resolve.test.ts rename to packages/cli/test/lib/mcp/config.test.ts index d3fe56251..c23d17ad7 100644 --- a/packages/mcp-server/src/cli/resolve.test.ts +++ b/packages/cli/test/lib/mcp/config.test.ts @@ -1,25 +1,20 @@ -import { describe, it, expect } from "vitest"; -import { finalize } from "./resolve"; +import { describe, expect, it } from "vitest"; +import type { McpMergedArgs } from "../../../src/lib/mcp/args.js"; +import { finalizeMcpConfig } from "../../../src/lib/mcp/config.js"; +import { ValidationError } from "../../../src/lib/errors.js"; -describe("cli/finalize", () => { - it("returns undefined accessToken when none provided", () => { - const cfg = finalize({ unknownArgs: [] } as any); - expect(cfg.accessToken).toBeUndefined(); - }); - - it("uses DEFAULT_SENTRY_CLIENT_ID when no clientId provided", () => { - const cfg = finalize({ accessToken: "tok", unknownArgs: [] }); - expect(cfg.clientId).toBeDefined(); - expect(typeof cfg.clientId).toBe("string"); +function finalize(input: Partial) { + return finalizeMcpConfig({ + agentProviderFromFlag: false, + unknownArgs: [], + ...input, }); +} - it("uses provided clientId over default", () => { - const cfg = finalize({ - accessToken: "tok", - clientId: "custom-client-id", - unknownArgs: [], - }); - expect(cfg.clientId).toBe("custom-client-id"); +describe("finalizeMcpConfig", () => { + it("returns undefined accessToken when none provided", () => { + const cfg = finalize({}); + expect(cfg.accessToken).toBeUndefined(); }); it("normalizes host from URL", () => { @@ -106,7 +101,7 @@ describe("cli/finalize", () => { insecureHttp: true, unknownArgs: [], }), - ).toThrow(/cannot be used with --url or SENTRY_URL/); + ).toThrow(/cannot be used with --url/); }); it("surfaces the --insecure-http + --url conflict before URL validation", () => { @@ -120,7 +115,7 @@ describe("cli/finalize", () => { insecureHttp: true, unknownArgs: [], }), - ).toThrow(/cannot be used with --url or SENTRY_URL/); + ).toThrow(/cannot be used with --url/); }); it.each(["sentry.io", "example.my.sentry.io"])( @@ -377,4 +372,44 @@ describe("cli/finalize", () => { expect(cfg.finalSkills.has("inspect")).toBe(true); expect(cfg.finalSkills.has("triage")).toBe(true); }); + + it("rejects unknown arguments with a pointer to --help", () => { + expect(() => finalize({ unknownArgs: ["--bogus"] })).toThrow( + /Invalid argument\(s\): --bogus\nRun `sentry mcp --help`/, + ); + }); + + it("reports invalid input as a CLI validation error", () => { + expect(() => finalize({ url: "http://bad" })).toThrow(ValidationError); + expect(() => finalize({ skills: "nope" })).toThrow(ValidationError); + }); + + it("keeps the static token, constraints, and embedded agent settings", () => { + const cfg = finalize({ + accessToken: "tok", + mcpUrl: "https://mcp.example.com", + sentryDsn: "dsn", + openaiModel: "gpt-test", + anthropicBaseUrl: "https://anthropic.example.com", + anthropicModel: "claude-test", + agentProvider: "Anthropic", + agentProviderFromFlag: true, + experimental: true, + organizationSlug: "acme", + projectSlug: "web", + }); + expect(cfg).toMatchObject({ + accessToken: "tok", + mcpUrl: "https://mcp.example.com", + sentryDsn: "dsn", + openaiModel: "gpt-test", + anthropicBaseUrl: "https://anthropic.example.com/", + anthropicModel: "claude-test", + agentProvider: "anthropic", + agentProviderFromFlag: true, + experimental: true, + organizationSlug: "acme", + projectSlug: "web", + }); + }); }); diff --git a/packages/cli/test/lib/mcp/process-handlers.test.ts b/packages/cli/test/lib/mcp/process-handlers.test.ts new file mode 100644 index 000000000..8469cd0de --- /dev/null +++ b/packages/cli/test/lib/mcp/process-handlers.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const sentry = vi.hoisted(() => ({ + flush: vi.fn((_timeout?: number) => Promise.resolve(true)), +})); + +vi.mock("@sentry/node", async (importOriginal) => ({ + ...(await importOriginal()), + captureException: vi.fn(), + flush: sentry.flush, +})); + +const { installProcessHandlers } = + await import("../../../src/lib/mcp/server.js"); + +type Handler = (...args: unknown[]) => void; + +const handlers = new Map(); +let exit: ReturnType; + +/** Let pending flush promises and their `.finally` callbacks run. */ +function settle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +beforeEach(() => { + // The fatal path arms a real-exit backstop timer; keep it from firing. + vi.useFakeTimers({ toFake: ["setTimeout"] }); + handlers.clear(); + // Capture the handlers instead of attaching them to the test process. + vi.spyOn(process, "on").mockImplementation((( + event: string, + handler: Handler, + ) => { + handlers.set(event, handler); + return process; + }) as typeof process.on); + exit = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as typeof process.exit); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + sentry.flush.mockImplementation(() => Promise.resolve(true)); +}); + +describe("installProcessHandlers", () => { + test("a signal during a fatal error's flush keeps the failing exit code", async () => { + const pending = Promise.withResolvers(); + sentry.flush.mockImplementation(() => pending.promise); + const exitAfterError = installProcessHandlers(); + + exitAfterError(new Error("boom"), "Server error:"); + handlers.get("SIGTERM")?.(); + pending.resolve(true); + await settle(); + + expect(exit).not.toHaveBeenCalledWith(0); + expect(exit).toHaveBeenCalledWith(1); + }); + + test("a signal alone shuts down cleanly", async () => { + installProcessHandlers(); + + handlers.get("SIGINT")?.(); + await settle(); + expect(exit).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/cli/test/lib/mcp/usage-standalone.test.ts b/packages/cli/test/lib/mcp/usage-standalone.test.ts new file mode 100644 index 000000000..c6135930c --- /dev/null +++ b/packages/cli/test/lib/mcp/usage-standalone.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe("standalone @sentry/mcp-server bundle", () => { + test("names `sentry-mcp` and reports the package version", async () => { + vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); + vi.resetModules(); + const { buildMcpUsage, buildMcpVersion } = + await import("../../../src/lib/mcp/usage.js"); + + expect(buildMcpVersion()).toBe("sentry-mcp 1.2.3"); + const usage = buildMcpUsage(["inspect"]); + expect(usage).toMatch(/^Usage: sentry-mcp /); + expect(usage).toContain("`npx -y @sentry/mcp-server auth login`"); + expect(usage).not.toContain("sentry mcp"); + }); + + test("auth errors name the package's own login command", async () => { + vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); + vi.resetModules(); + const { AuthError } = await import("../../../src/lib/errors.js"); + + expect(new AuthError("not_authenticated").message).toBe( + "Not authenticated. Run 'npx -y @sentry/mcp-server auth login' first.", + ); + }); +}); diff --git a/packages/cli/test/lib/mcp/usage.test.ts b/packages/cli/test/lib/mcp/usage.test.ts new file mode 100644 index 000000000..f4ab04146 --- /dev/null +++ b/packages/cli/test/lib/mcp/usage.test.ts @@ -0,0 +1,88 @@ +import { ALL_SKILLS } from "@sentry/mcp-core/skills"; +import { describe, expect, it } from "vitest"; +import { CLI_VERSION } from "../../../src/lib/constants.js"; +import { parseMcpInvocation } from "../../../src/lib/mcp/server.js"; +import { buildMcpUsage } from "../../../src/lib/mcp/usage.js"; + +describe("buildMcpUsage", () => { + it("documents CLI session auth and the static token override", () => { + const usage = buildMcpUsage(["inspect"]); + + expect(usage).toContain("Uses the active Sentry CLI session"); + expect(usage).toContain("sentry auth login"); + expect(usage).toContain("--access-token "); + expect(usage).toContain("SENTRY_ACCESS_TOKEN"); + expect(usage).toContain("Available skills: inspect"); + expect(usage).not.toContain("device code"); + expect(usage).not.toContain("auth [login|logout|status]"); + }); + + it("lists every server flag", () => { + const usage = buildMcpUsage(ALL_SKILLS); + for (const flag of [ + "--access-token", + "--host", + "--url", + "--insecure-http", + "--mcp-url", + "--sentry-dsn", + "--experimental", + "--help", + "--version", + "--agent-provider", + "--openai-base-url", + "--openai-model", + "--anthropic-base-url", + "--anthropic-model", + "--organization-slug", + "--project-slug", + "--skills", + "--all-skills", + "--disable-skills", + ]) { + expect(usage).toContain(flag); + } + }); +}); + +describe("parseMcpInvocation", () => { + it.each([["--help"], ["-h"]])("prints usage for %s", (flag) => { + expect(parseMcpInvocation([flag], {})).toEqual({ + kind: "output", + text: buildMcpUsage(ALL_SKILLS), + }); + }); + + it.each([["--version"], ["-v"]])("prints the CLI version for %s", (flag) => { + expect(parseMcpInvocation([flag], {})).toEqual({ + kind: "output", + text: `sentry mcp ${CLI_VERSION}`, + }); + }); + + it("prints help even when other arguments are invalid", () => { + expect(parseMcpInvocation(["--bogus", "--help"], {}).kind).toBe("output"); + }); + + it("builds a server config from flags and the environment", () => { + const invocation = parseMcpInvocation( + ["--host=sentry.example.com", "--skills=inspect"], + { SENTRY_ACCESS_TOKEN: "envtok" }, + ); + expect(invocation).toMatchObject({ + kind: "serve", + config: { + accessToken: "envtok", + sentryHost: "sentry.example.com", + sentryProtocol: "https", + finalSkills: new Set(["inspect"]), + }, + }); + }); + + it("rejects unknown arguments", () => { + expect(() => parseMcpInvocation(["--bogus"], {})).toThrow( + "Invalid argument(s): --bogus", + ); + }); +}); diff --git a/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts b/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts index 558edcd4e..fce0b4444 100644 --- a/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts +++ b/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts @@ -77,7 +77,7 @@ describe("CVE: .sentryclirc URL credential exfiltration", () => { delete process.env.SENTRY_URL; // SENTRY_AUTH_TOKEN is already set by test/preload.ts // Capture env-token host BEFORE the shim mutates env (mirrors boot - // ordering in cli.ts::preloadProjectContext). + // ordering in lib/project-context.ts::preloadProjectContext). captureEnvTokenHost(); writeRc(testDir, "[defaults]\nurl = https://evil.com\n"); diff --git a/packages/mcp-core/README.md b/packages/mcp-core/README.md index 734fee570..695422d06 100644 --- a/packages/mcp-core/README.md +++ b/packages/mcp-core/README.md @@ -2,7 +2,7 @@ This is a prototype of an MCP server, acting as a middleware to the upstream Sentry API provider. -This package is primarily for running the `stdio` MCP server. If you do not know what that is, or do not need it, we suggest using the public remote service: +This package holds the MCP tools and server shared by the hosted service and the local `stdio` server, which ships as [`@sentry/mcp-server`](https://www.npmjs.com/package/@sentry/mcp-server) (also available as `sentry mcp` in the [`sentry` CLI](https://www.npmjs.com/package/sentry)). If you do not know what that is, or do not need it, we suggest using the public remote service: @@ -120,10 +120,10 @@ If you're currently using scopes: ```shell # OLD (deprecated) -npx @sentry/mcp-server --access-token=TOKEN --scopes=org:read,event:write +npx @sentry/mcp-server@latest --access-token=TOKEN --scopes=org:read,event:write # NEW (recommended) -npx @sentry/mcp-server --access-token=TOKEN --skills=inspect,triage +npx @sentry/mcp-server@latest --access-token=TOKEN --skills=inspect,triage ``` The host configuration accepts two distinct formats: diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 123d00191..0e37b5511 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -140,6 +140,10 @@ "./scopes": { "types": "./dist/scopes.d.ts", "default": "./dist/scopes.js" + }, + "./transports/stdio": { + "types": "./dist/transports/stdio.d.ts", + "default": "./dist/transports/stdio.js" } }, "scripts": { diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index d0a99a772..f4d24a1d2 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -10,6 +10,7 @@ import { import { HttpResponse, http } from "msw"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { ApiAuthenticationError } from "./api-client"; import { UserInputError } from "./errors"; import { structuredResult } from "./internal/tool-helpers/results"; import { buildServer } from "./server"; @@ -175,6 +176,48 @@ describe("buildServer", () => { ...options, }); + describe("upstream 401", () => { + it("uses the token returned by onUpstreamUnauthorized for later calls", async () => { + const seenTokens: string[] = []; + const server = buildServer({ + context: { + ...baseContext, + accessToken: "expired-token", + onUpstreamUnauthorized: async () => "fresh-token", + }, + tools: { + token_tool: createMockTool("token_tool", { + handler: async (_params, context) => { + seenTokens.push(context.accessToken); + if (context.accessToken === "expired-token") { + throw new ApiAuthenticationError("Unauthorized"); + } + return "ok"; + }, + }), + }, + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: "server-test-client", + version: "1.0.0", + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + await client.callTool({ name: "token_tool", arguments: {} }); + await client.callTool({ name: "token_tool", arguments: {} }); + } finally { + await client.close(); + await server.close(); + } + + expect(seenTokens).toEqual(["expired-token", "fresh-token"]); + }); + }); + describe("tool failure telemetry", () => { it("invokes the tool's onError hook when its handler throws", async () => { const onError = vi.fn(); diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index fc8df3d30..42770317e 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -113,7 +113,7 @@ function structuredOutputToCallToolResult( * @example Usage with stdio transport * ```typescript * import { buildServer } from "@sentry/mcp-core/server"; - * import { startStdio } from "@sentry/mcp-server/transports/stdio"; + * import { startStdio } from "@sentry/mcp-core/transports/stdio"; * * const context = { * accessToken: process.env.SENTRY_TOKEN, @@ -401,14 +401,18 @@ function configureServer({ } // Upstream 401 during a tool call — route via the transport so it - // can revoke the MCP grant; swallow callback errors since the - // formatted tool response still needs to land. + // can revoke the MCP grant or refresh the token; swallow callback + // errors since the formatted tool response still needs to land. if ( isApiAuthenticationErrorDeep(error) && context.onUpstreamUnauthorized ) { try { - await context.onUpstreamUnauthorized(); + const refreshedToken = await context.onUpstreamUnauthorized(); + // Handlers read this shared copy, not the caller's context. + if (refreshedToken) { + contextWithToolAvailability.accessToken = refreshedToken; + } } catch {} } diff --git a/packages/mcp-core/src/transports/stdio.ts b/packages/mcp-core/src/transports/stdio.ts new file mode 100644 index 000000000..731501c25 --- /dev/null +++ b/packages/mcp-core/src/transports/stdio.ts @@ -0,0 +1,71 @@ +/** + * Standard I/O transport for the MCP server. + * + * Connects a built server to stdin/stdout so a host process (such as + * `sentry mcp`) can serve MCP over pipes. Host-specific setup — argument + * parsing, credentials, and SDK initialization — stays with the host. + * + * @example + * ```typescript + * import { buildServer } from "@sentry/mcp-core/server"; + * import { startStdio } from "@sentry/mcp-core/transports/stdio"; + * + * const server = buildServer({ context }); + * await startStdio(server, context); + * ``` + */ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { startNewTrace, startSpan } from "@sentry/core"; +import { LIB_VERSION } from "../version"; + +export type StdioServerContext = { + sentryHost?: string; + mcpUrl?: string; + experimentalMode?: boolean; +}; + +function getStdioSpanAttributes( + context: StdioServerContext, +): Record { + const attributes: Record = { + "app.transport": "stdio", + "app.server.version": LIB_VERSION, + "app.server.mode.experimental": context.experimentalMode ?? false, + "network.transport": "pipe", + "service.version": LIB_VERSION, + }; + + if (context.sentryHost) { + attributes["app.upstream.host"] = context.sentryHost; + } + if (context.mcpUrl) { + attributes["app.url.full"] = context.mcpUrl; + } + + return attributes; +} + +/** + * Connect the server to stdio inside a `mcp.server/stdio` span. + * + * Context is already captured in tool handler closures by `buildServer()`; + * it is only read here for telemetry attributes. + */ +export async function startStdio( + server: McpServer, + context: Context, +) { + await startNewTrace(async () => { + return await startSpan( + { + name: "mcp.server/stdio", + attributes: getStdioSpanAttributes(context), + }, + async () => { + const transport = new StdioServerTransport(); + await server.connect(transport); + }, + ); + }); +} diff --git a/packages/mcp-core/src/types.ts b/packages/mcp-core/src/types.ts index 971ba827a..b4558c079 100644 --- a/packages/mcp-core/src/types.ts +++ b/packages/mcp-core/src/types.ts @@ -71,8 +71,9 @@ export type ServerContext = { /** * Invoked when a tool call surfaces an upstream 401. Transports wire this * to revoke the MCP grant so the session doesn't keep getting wrapper - * tokens backed by an upstream token Sentry has already rejected. Callback - * errors are swallowed. + * tokens backed by an upstream token Sentry has already rejected. Hosts + * that can refresh their credential return the new token, which later tool + * calls use. Callback errors are swallowed. */ - onUpstreamUnauthorized?: () => void | Promise; + onUpstreamUnauthorized?: () => void | string | Promise; }; diff --git a/packages/mcp-server-evals/README.md b/packages/mcp-server-evals/README.md index 89f73105a..43ac46e1b 100644 --- a/packages/mcp-server-evals/README.md +++ b/packages/mcp-server-evals/README.md @@ -10,7 +10,7 @@ Evaluation helpers and a local mock stdio runner used when developing and valida ### Scopes policy -The mock stdio script grants only the high-level admin scopes that imply all lower permissions via the hierarchy defined in `packages/mcp-server/src/permissions.ts`: +The mock stdio script grants only the high-level admin scopes that imply all lower permissions via the hierarchy defined in `packages/mcp-core/src/permissions.ts`: - `org:admin`, `project:admin`, `team:admin`, `member:admin`, `event:admin` - Plus special non-hierarchical scope: `project:releases` diff --git a/packages/mcp-server-evals/package.json b/packages/mcp-server-evals/package.json index 393716cff..b98e228f0 100644 --- a/packages/mcp-server-evals/package.json +++ b/packages/mcp-server-evals/package.json @@ -19,7 +19,6 @@ "@ai-sdk/openai": "catalog:", "@modelcontextprotocol/sdk": "catalog:", "@sentry/mcp-core": "workspace:*", - "@sentry/mcp-server": "workspace:*", "@sentry/mcp-server-mocks": "workspace:*", "@sentry/mcp-server-tsconfig": "workspace:*", "ai": "catalog:", diff --git a/packages/mcp-server-evals/src/bin/start-mock-stdio.ts b/packages/mcp-server-evals/src/bin/start-mock-stdio.ts index 367afff96..b0b3040af 100644 --- a/packages/mcp-server-evals/src/bin/start-mock-stdio.ts +++ b/packages/mcp-server-evals/src/bin/start-mock-stdio.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { buildServer } from "@sentry/mcp-core/server"; -import { startStdio } from "@sentry/mcp-server/transports/stdio"; +import { startStdio } from "@sentry/mcp-core/transports/stdio"; import { mswServer } from "@sentry/mcp-server-mocks"; import { SKILLS, type Skill } from "@sentry/mcp-core/skills"; import type { ServerContext } from "@sentry/mcp-core/types"; diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..803928575 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,35 @@ +# @sentry/mcp-server + +The Sentry MCP server for stdio clients, as a single file with no runtime +dependencies. Works with sentry.io and self-hosted Sentry. + +```json +{ + "command": "npx", + "args": ["-y", "@sentry/mcp-server@latest"] +} +``` + +Requires Node.js 22.15 or later. + +## Authentication + +- `npx -y @sentry/mcp-server auth login` signs in once. The login is shared + with the [`sentry` CLI](https://cli.sentry.dev), so an existing + `sentry auth login` works too. +- `--access-token` / `SENTRY_ACCESS_TOKEN` passes a static token instead. +- Each server process uses one account. Log out, check status, or switch + accounts with `npx -y sentry auth logout|status|switch`. + +## Options + +Run `npx -y @sentry/mcp-server --help` for the flags and environment +variables. For self-hosted Sentry, pass `--host` or `--url`, or set +`SENTRY_HOST` / `SENTRY_URL`. + +## Development + +The source lives in `packages/cli` so the server shares the CLI's auth. +`pnpm run bundle` builds `dist/sentry-mcp.cjs` from +`packages/cli/src/mcp-server.ts`, and `pnpm run test:bundle` smoke-tests the +result. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index a54ee8bed..bde7a3445 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -2,70 +2,45 @@ "name": "@sentry/mcp-server", "mcpName": "io.github.getsentry/sentry-mcp", "version": "0.42.0", + "description": "Sentry MCP server over stdio: the CLI's `sentry mcp` server, bundled without the rest of the CLI", + "sentryCi": { + "build": "bundle", + "test": "test:bundle", + "dependencies": [ + "sentry" + ] + }, "type": "module", "packageManager": "pnpm@11.8.0", "engines": { - "node": ">=22.13" + "node": ">=22.15" }, "publishConfig": { "access": "public" }, "license": "FSL-1.1-ALv2", "author": "Sentry", - "homepage": "https://github.com/getsentry/sentry-mcp", + "homepage": "https://github.com/getsentry/toolkit", "keywords": [ "sentry", "mcp", "model-context-protocol" ], "bugs": { - "url": "https://github.com/getsentry/sentry-mcp/issues" + "url": "https://github.com/getsentry/toolkit/issues" }, "repository": { "type": "git", - "url": "git@github.com:getsentry/sentry-mcp.git" + "url": "git+https://github.com/getsentry/toolkit.git" }, "bin": { - "sentry-mcp": "./dist/index.js" + "sentry-mcp": "./dist/sentry-mcp.cjs" }, "files": [ - "./dist/*" + "dist/sentry-mcp.cjs" ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./transports/stdio": { - "types": "./dist/transports/stdio.d.ts", - "default": "./dist/transports/stdio.js" - } - }, "scripts": { - "build": "tsdown", - "dev": "tsdown --watch", - "start": "tsx src/index.ts", - "test": "vitest run", - "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", - "test:watch": "vitest", - "tsc": "tsc --noEmit" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "catalog:", - "@sentry/node": "catalog:", - "@sentry/core": "catalog:", - "dotenv": "catalog:", - "zod": "catalog:" - }, - "devDependencies": { - "@sentry/mcp-core": "workspace:*", - "@sentry/mcp-server-mocks": "workspace:*", - "@sentry/mcp-server-tsconfig": "workspace:*", - "@sentry/toolkit-core": "workspace:*", - "@types/node": "catalog:", - "tsdown": "catalog:", - "tsx": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" + "bundle": "pnpm --filter sentry run bundle:mcp-server", + "test:bundle": "node --test test/bundle.test.mjs" } } diff --git a/packages/mcp-server/src/auth/constants.ts b/packages/mcp-server/src/auth/constants.ts deleted file mode 100644 index 4655b4341..000000000 --- a/packages/mcp-server/src/auth/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SCOPES } from "@sentry/mcp-core/scopes"; - -/** - * Public OAuth app registered on sentry.io for the stdio device code flow. - * No client secret — device code grant is a public client flow. - * The Cloudflare transport uses a separate OAuth app with a secret. - * Override via SENTRY_CLIENT_ID environment variable. - * See docs/operations/stdio-auth.md for the full credential architecture. - */ -export const DEFAULT_SENTRY_CLIENT_ID = - "0acbeba7d07d58076dd7dbde8cea2fed8ab525ce3713bda604988009ab35d765"; - -export const DEVICE_CODE_ENDPOINT = "/oauth/device/code/"; -export const TOKEN_ENDPOINT = "/oauth/token/"; -export const DEVICE_CODE_SCOPES = Object.keys(SCOPES).join(" "); - -/** - * OAuth endpoints live on sentry.io regardless of regional host. - * Always use this as the host for device code and token requests. - */ -export const OAUTH_HOST = "sentry.io"; diff --git a/packages/mcp-server/src/auth/device-code-flow.test.ts b/packages/mcp-server/src/auth/device-code-flow.test.ts deleted file mode 100644 index 61d63c266..000000000 --- a/packages/mcp-server/src/auth/device-code-flow.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - requestDeviceCode, - pollForToken, - DeviceCodeError, -} from "./device-code-flow"; - -const mockDeviceCodeResponse = { - device_code: "test-device-code", - user_code: "ABCD-1234", - verification_uri: "https://sentry.io/oauth/device", - verification_uri_complete: - "https://sentry.io/oauth/device?user_code=ABCD-1234", - interval: 1, - expires_in: 600, -}; - -const mockTokenResponse = { - access_token: "sntrys_test_access_token", - refresh_token: "sntrys_test_refresh_token", - token_type: "bearer", - expires_in: 2592000, - expires_at: new Date(Date.now() + 2592000 * 1000).toISOString(), - user: { - email: "test@example.com", - id: "12345", - name: "Test User", - }, - scope: "org:read project:write team:write event:write", -}; - -describe("requestDeviceCode", () => { - beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("sends correct request and parses response", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify(mockDeviceCodeResponse), { status: 200 }), - ); - - const result = await requestDeviceCode( - "test-client-id", - "sentry.io", - "org:read", - ); - - expect(fetch).toHaveBeenCalledWith( - "https://sentry.io/oauth/device/code/", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/x-www-form-urlencoded", - }), - }), - ); - expect(result.device_code).toBe("test-device-code"); - expect(result.user_code).toBe("ABCD-1234"); - }); - - it("throws on non-ok response", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response("Bad Request", { status: 400 }), - ); - - await expect(requestDeviceCode("bad-client", "sentry.io")).rejects.toThrow( - DeviceCodeError, - ); - }); -}); - -describe("pollForToken", () => { - beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns token on immediate success", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify(mockTokenResponse), { status: 200 }), - ); - - const result = await pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, // Fast polling for tests - expiresIn: 10, - }); - - expect(result.access_token).toBe("sntrys_test_access_token"); - expect(result.user.email).toBe("test@example.com"); - }); - - it("retries on authorization_pending", async () => { - vi.mocked(fetch) - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "authorization_pending" }), { - status: 400, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(mockTokenResponse), { status: 200 }), - ); - - const result = await pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, - expiresIn: 10, - }); - - expect(fetch).toHaveBeenCalledTimes(2); - expect(result.access_token).toBe("sntrys_test_access_token"); - }); - - it("throws on access_denied", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ error: "access_denied" }), { status: 400 }), - ); - - await expect( - pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, - expiresIn: 10, - }), - ).rejects.toThrow(/Authorization was denied/); - }); - - it("throws on expired_token", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ error: "expired_token" }), { status: 400 }), - ); - - await expect( - pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, - expiresIn: 10, - }), - ).rejects.toThrow(/expired/); - }); - - it("reports an unexpected token error without retrying", async () => { - const fetchMock = vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ error: "invalid_grant" }), { - status: 400, - }), - ); - - await expect( - pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, - expiresIn: 10, - }), - ).rejects.toMatchObject({ - name: "DeviceCodeError", - code: "invalid_grant", - message: "Unexpected error during device code polling: invalid_grant", - }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("increases interval on slow_down", async () => { - vi.useFakeTimers(); - const fetchMock = vi.mocked(fetch); - fetchMock - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "slow_down" }), { status: 400 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(mockTokenResponse), { status: 200 }), - ); - - const promise = pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 1, - expiresIn: 30, - }); - - // First poll after 1s interval - await vi.advanceTimersByTimeAsync(1000); - // After slow_down, interval becomes 1 + 5 = 6s - await vi.advanceTimersByTimeAsync(6000); - - const result = await promise; - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(result.access_token).toBe("sntrys_test_access_token"); - vi.useRealTimers(); - }); -}); diff --git a/packages/mcp-server/src/auth/device-code-flow.ts b/packages/mcp-server/src/auth/device-code-flow.ts deleted file mode 100644 index c18bb21a7..000000000 --- a/packages/mcp-server/src/auth/device-code-flow.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { exec } from "node:child_process"; -import * as fs from "node:fs"; -import { LIB_VERSION } from "@sentry/mcp-core/version"; -import { - deviceCodeRequestBody, - deviceTokenRequestBody, -} from "@sentry/toolkit-core/oauth-device"; -import { advanceDevicePoll } from "@sentry/toolkit-core/oauth-poll"; -import { - DEVICE_CODE_ENDPOINT, - DEVICE_CODE_SCOPES, - TOKEN_ENDPOINT, -} from "./constants"; -import { - DeviceCodeErrorSchema, - type DeviceCodeResponse, - DeviceCodeResponseSchema, - getTokenUserLabel, - type TokenResponse, - TokenResponseSchema, -} from "./types"; - -const USER_AGENT = `sentry-mcp-server/${LIB_VERSION}`; - -export class DeviceCodeError extends Error { - constructor( - message: string, - public readonly code?: string, - ) { - super(message); - this.name = "DeviceCodeError"; - } -} - -export async function requestDeviceCode( - clientId: string, - host: string, - scopes: string = DEVICE_CODE_SCOPES, -): Promise { - const url = `https://${host}${DEVICE_CODE_ENDPOINT}`; - const resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": USER_AGENT, - }, - body: deviceCodeRequestBody(clientId, scopes), - }); - - if (!resp.ok) { - const text = await resp.text(); - throw new DeviceCodeError( - `Failed to request device code (HTTP ${resp.status}): ${text}`, - ); - } - - const body = await resp.json(); - return DeviceCodeResponseSchema.parse(body); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function pollForToken({ - deviceCode, - clientId, - host, - interval, - expiresIn, -}: { - deviceCode: string; - clientId: string; - host: string; - interval: number; - expiresIn: number; -}): Promise { - const url = `https://${host}${TOKEN_ENDPOINT}`; - const deadline = Date.now() + expiresIn * 1000; - let pollInterval = interval; - - while (Date.now() < deadline) { - await sleep(pollInterval * 1000); - - const resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": USER_AGENT, - }, - body: deviceTokenRequestBody(clientId, deviceCode), - }); - - if (resp.ok) { - const body = await resp.json(); - return TokenResponseSchema.parse(body); - } - - const errorBody = await resp.json().catch(() => null); - const parsed = DeviceCodeErrorSchema.safeParse(errorBody); - const errorCode = parsed.success ? parsed.data.error : undefined; - - const outcome = advanceDevicePoll(pollInterval, errorCode); - switch (outcome.status) { - case "retry": - pollInterval = outcome.intervalSeconds; - continue; - case "denied": - throw new DeviceCodeError( - "Authorization was denied. Please try again or provide --access-token.", - errorCode, - ); - case "expired": - throw new DeviceCodeError( - "Device code expired before authorization was completed.", - errorCode, - ); - case "unexpected": - throw new DeviceCodeError( - `Unexpected error during device code polling: ${errorCode ?? resp.statusText}`, - errorCode, - ); - } - } - - throw new DeviceCodeError( - "Device code expired before authorization was completed.", - "expired_token", - ); -} - -/** - * Write directly to stderr, bypassing console.warn which may be - * intercepted by the MCP transport layer. - */ -function stderr(msg: string): void { - process.stderr.write(`${msg}\n`); -} - -function isWSL(): boolean { - try { - return fs - .readFileSync("/proc/version", "utf-8") - .toLowerCase() - .includes("microsoft"); - } catch { - return false; - } -} - -function openBrowser(url: string): void { - try { - const { platform } = process; - if (platform === "darwin") { - exec(`open ${JSON.stringify(url)}`); - } else if (platform === "win32") { - exec(`start "" ${JSON.stringify(url)}`); - } else if (isWSL()) { - // WSL: invoke cmd.exe to open the default browser. - exec(`cmd.exe /c start "" ${JSON.stringify(url)}`); - } else { - exec(`xdg-open ${JSON.stringify(url)}`); - } - } catch { - // Best-effort — ignore errors (headless, etc.) - } -} - -export function displayDeviceCode(response: DeviceCodeResponse): void { - stderr(""); - stderr(`To authorize, visit: ${response.verification_uri_complete}`); - stderr(` and enter code: ${response.user_code}`); - stderr(""); - stderr("Waiting for authorization..."); - - openBrowser(response.verification_uri_complete); -} - -export async function authenticate({ - clientId, - host, -}: { - clientId: string; - host: string; -}): Promise { - stderr("No access token provided. Starting device authorization..."); - - const deviceCodeResponse = await requestDeviceCode(clientId, host); - displayDeviceCode(deviceCodeResponse); - - const tokenResponse = await pollForToken({ - deviceCode: deviceCodeResponse.device_code, - clientId, - host, - interval: deviceCodeResponse.interval, - expiresIn: deviceCodeResponse.expires_in, - }); - - stderr(`Successfully authenticated as ${getTokenUserLabel(tokenResponse)}`); - return tokenResponse; -} diff --git a/packages/mcp-server/src/auth/resolve-token.test.ts b/packages/mcp-server/src/auth/resolve-token.test.ts deleted file mode 100644 index 0f1debb20..000000000 --- a/packages/mcp-server/src/auth/resolve-token.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { resolveAccessToken } from "./resolve-token"; -import type { PartiallyResolvedConfig } from "../cli/types"; - -// Mock the auth modules so we never make real network calls or touch disk -vi.mock("./device-code-flow", () => ({ - authenticate: vi.fn(), -})); - -vi.mock("./token-cache", () => ({ - readCachedToken: vi.fn().mockResolvedValue(null), - writeCachedToken: vi.fn().mockResolvedValue(undefined), -})); - -function makePartialConfig( - overrides: Partial = {}, -): PartiallyResolvedConfig { - return { - clientId: "test-client-id", - sentryHost: "sentry.io", - finalSkills: new Set(), - ...overrides, - }; -} - -describe("resolveAccessToken", () => { - let originalIsTTY: boolean | undefined; - - beforeEach(() => { - originalIsTTY = process.stderr.isTTY; - }); - - afterEach(() => { - Object.defineProperty(process.stderr, "isTTY", { - value: originalIsTTY, - writable: true, - configurable: true, - }); - vi.restoreAllMocks(); - }); - - it("returns immediately when accessToken is provided", async () => { - const cfg = makePartialConfig({ accessToken: "existing-token" }); - const result = await resolveAccessToken(cfg); - expect(result.accessToken).toBe("existing-token"); - }); - - it("throws for non-sentry.io hosts without a token", async () => { - const cfg = makePartialConfig({ - sentryHost: "sentry.example.com", - }); - await expect(resolveAccessToken(cfg)).rejects.toThrow( - /only supported for sentry.io/, - ); - }); - - it("throws in non-TTY context when no token and no cache", async () => { - Object.defineProperty(process.stderr, "isTTY", { - value: undefined, - writable: true, - configurable: true, - }); - - const cfg = makePartialConfig(); - await expect(resolveAccessToken(cfg)).rejects.toThrow( - /Run `sentry-mcp auth login` interactively first/, - ); - }); - - it("uses cached token in non-TTY context", async () => { - Object.defineProperty(process.stderr, "isTTY", { - value: undefined, - writable: true, - configurable: true, - }); - - const { readCachedToken } = await import("./token-cache"); - vi.mocked(readCachedToken).mockResolvedValueOnce({ - access_token: "cached-token", - refresh_token: "refresh", - expires_at: new Date(Date.now() + 86400000).toISOString(), - sentry_host: "sentry.io", - client_id: "test-client-id", - user_email: "test@example.com", - scope: "org:read", - }); - - const cfg = makePartialConfig(); - const result = await resolveAccessToken(cfg); - expect(result.accessToken).toBe("cached-token"); - }); - - it("triggers device code flow in TTY context when no cache", async () => { - Object.defineProperty(process.stderr, "isTTY", { - value: true, - writable: true, - configurable: true, - }); - - const { authenticate } = await import("./device-code-flow"); - vi.mocked(authenticate).mockResolvedValueOnce({ - access_token: "new-token", - refresh_token: "refresh", - token_type: "bearer", - expires_in: 2592000, - expires_at: new Date(Date.now() + 2592000000).toISOString(), - user: { email: "test@example.com", id: "1", name: "Test" }, - scope: "org:read", - }); - - const cfg = makePartialConfig(); - const result = await resolveAccessToken(cfg); - expect(result.accessToken).toBe("new-token"); - expect(authenticate).toHaveBeenCalledWith({ - clientId: "test-client-id", - host: "sentry.io", - }); - }); - - it("accepts regional sentry.io subdomains", async () => { - Object.defineProperty(process.stderr, "isTTY", { - value: true, - writable: true, - configurable: true, - }); - - const { authenticate } = await import("./device-code-flow"); - vi.mocked(authenticate).mockResolvedValueOnce({ - access_token: "regional-token", - refresh_token: "refresh", - token_type: "bearer", - expires_in: 2592000, - expires_at: new Date(Date.now() + 2592000000).toISOString(), - user: { email: "test@example.com", id: "1", name: "Test" }, - scope: "org:read", - }); - - const cfg = makePartialConfig({ sentryHost: "us.sentry.io" }); - const result = await resolveAccessToken(cfg); - expect(result.accessToken).toBe("regional-token"); - // OAuth endpoints always use sentry.io, not the regional subdomain - expect(authenticate).toHaveBeenCalledWith({ - clientId: "test-client-id", - host: "sentry.io", - }); - }); -}); diff --git a/packages/mcp-server/src/auth/resolve-token.ts b/packages/mcp-server/src/auth/resolve-token.ts deleted file mode 100644 index fbb3c72a0..000000000 --- a/packages/mcp-server/src/auth/resolve-token.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; -import type { PartiallyResolvedConfig, ResolvedConfig } from "../cli/types"; -import { OAUTH_HOST } from "./constants"; -import { authenticate } from "./device-code-flow"; -import { readCachedToken, writeCachedToken } from "./token-cache"; -import { toCachedToken } from "./types"; - -function withToken( - partial: PartiallyResolvedConfig, - accessToken: string, -): ResolvedConfig { - return { ...partial, accessToken }; -} - -/** - * Resolves the access token for the session. - * - * If an access token is already provided, returns immediately. - * If the host is sentry.io, attempts to use a cached token or - * initiates the device code flow (only when stderr is a TTY). - * For non-sentry.io hosts, throws an error requiring --access-token. - */ -export async function resolveAccessToken( - partial: PartiallyResolvedConfig, -): Promise { - if (partial.accessToken) { - return withToken(partial, partial.accessToken); - } - - if (!isSentryHost(partial.sentryHost)) { - throw new Error( - "Error: No access token was provided. Device code authentication is only supported for sentry.io.\n" + - "Pass one with `--access-token` or via `SENTRY_ACCESS_TOKEN`.", - ); - } - - const { clientId, sentryHost } = partial; - - // Try cached token first (works in both interactive and non-interactive contexts) - try { - const cached = await readCachedToken(sentryHost, clientId); - if (cached) { - process.stderr.write( - `Using cached authentication for ${cached.user_email}\n`, - ); - return withToken(partial, cached.access_token); - } - } catch { - // Cache read failure is non-fatal - } - - // Device code flow requires a human to visit a URL and authorize. - // In non-interactive contexts (CI, piped stdio), fail immediately - // instead of hanging on the polling loop until expiry. - if (!process.stderr.isTTY) { - throw new Error( - "Error: No access token was provided.\n" + - "Run `sentry-mcp auth login` interactively first, or pass `--access-token` / `SENTRY_ACCESS_TOKEN`.", - ); - } - - const tokenResponse = await authenticate({ clientId, host: OAUTH_HOST }); - - try { - await writeCachedToken(toCachedToken(tokenResponse, sentryHost, clientId)); - } catch { - process.stderr.write("Warning: Could not cache authentication token.\n"); - } - - return withToken(partial, tokenResponse.access_token); -} diff --git a/packages/mcp-server/src/auth/token-cache.test.ts b/packages/mcp-server/src/auth/token-cache.test.ts deleted file mode 100644 index b1cd3ed3b..000000000 --- a/packages/mcp-server/src/auth/token-cache.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import * as os from "node:os"; -import { - readCachedToken, - writeCachedToken, - clearCachedToken, -} from "./token-cache"; -import type { CachedToken } from "./types"; - -let tmpDir: string; - -function makeCachedToken(overrides: Partial = {}): CachedToken { - return { - access_token: "sntrys_test_token", - refresh_token: "sntrys_refresh_token", - expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - sentry_host: "sentry.io", - client_id: "test-client-id", - user_email: "user@example.com", - scope: "org:read project:write team:write event:write", - ...overrides, - }; -} - -beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-auth-test-")); - process.env.SENTRY_MCP_AUTH_CACHE = path.join(tmpDir, "mcp.json"); -}); - -afterEach(async () => { - process.env.SENTRY_MCP_AUTH_CACHE = undefined; - await fs.rm(tmpDir, { recursive: true, force: true }); -}); - -describe("token-cache", () => { - it("returns null when no cache file exists", async () => { - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toBeNull(); - }); - - it("writes and reads a token", async () => { - const token = makeCachedToken(); - await writeCachedToken(token); - - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toEqual(token); - }); - - it("returns null for a different host/clientId pair", async () => { - await writeCachedToken(makeCachedToken()); - - const result = await readCachedToken("other.sentry.io", "test-client-id"); - expect(result).toBeNull(); - }); - - it("returns null and clears expired tokens", async () => { - const expired = makeCachedToken({ - expires_at: new Date(Date.now() - 1000).toISOString(), - }); - await writeCachedToken(expired); - - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toBeNull(); - - // Verify it was cleared from the file - const raw = await fs.readFile(process.env.SENTRY_MCP_AUTH_CACHE!, "utf-8"); - const data = JSON.parse(raw); - expect(data["sentry.io:test-client-id"]).toBeUndefined(); - }); - - it("returns null for tokens expiring within safety window", async () => { - const almostExpired = makeCachedToken({ - // Expires in 2 minutes (within 5-minute safety window) - expires_at: new Date(Date.now() + 2 * 60 * 1000).toISOString(), - }); - await writeCachedToken(almostExpired); - - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toBeNull(); - }); - - it("clears a specific token", async () => { - await writeCachedToken(makeCachedToken()); - await writeCachedToken( - makeCachedToken({ - sentry_host: "other.sentry.io", - client_id: "other-id", - }), - ); - - await clearCachedToken("sentry.io", "test-client-id"); - - const cleared = await readCachedToken("sentry.io", "test-client-id"); - expect(cleared).toBeNull(); - - // Other entry should still exist - const other = await readCachedToken("other.sentry.io", "other-id"); - expect(other).not.toBeNull(); - }); - - it("handles corrupted cache file gracefully", async () => { - await fs.writeFile(process.env.SENTRY_MCP_AUTH_CACHE!, "not-json", "utf-8"); - - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toBeNull(); - }); - - it("sets restrictive file permissions", async () => { - await writeCachedToken(makeCachedToken()); - - const stat = await fs.stat(process.env.SENTRY_MCP_AUTH_CACHE!); - // 0o600 = owner read/write only (on Unix-like systems) - const mode = stat.mode & 0o777; - expect(mode).toBe(0o600); - }); -}); diff --git a/packages/mcp-server/src/auth/token-cache.ts b/packages/mcp-server/src/auth/token-cache.ts deleted file mode 100644 index beaca5bbf..000000000 --- a/packages/mcp-server/src/auth/token-cache.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Persistent token cache for device code auth. - * Tokens are stored at ~/.sentry/mcp.json, keyed by {host}:{clientId}. - * See docs/operations/stdio-auth.md for cache format and security details. - */ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import * as os from "node:os"; -import type { CachedToken } from "./types"; - -/** Safety window: treat tokens as expired 5 minutes before actual expiry. */ -const EXPIRY_SAFETY_MS = 5 * 60 * 1000; - -function getCacheFilePath(): string { - if (process.env.SENTRY_MCP_AUTH_CACHE) { - return process.env.SENTRY_MCP_AUTH_CACHE; - } - return path.join(os.homedir(), ".sentry", "mcp.json"); -} - -function cacheKey(host: string, clientId: string): string { - return `${host}:${clientId}`; -} - -type CacheFile = Record; - -async function readCacheFile(): Promise { - try { - const raw = await fs.readFile(getCacheFilePath(), "utf-8"); - return JSON.parse(raw) as CacheFile; - } catch { - return {}; - } -} - -async function writeCacheFile(data: CacheFile): Promise { - const filePath = getCacheFilePath(); - const dir = path.dirname(filePath); - - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - - // Atomic write: write to temp file then rename - const tmpPath = `${filePath}.tmp.${process.pid}`; - await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), { - encoding: "utf-8", - mode: 0o600, - }); - await fs.rename(tmpPath, filePath); -} - -export async function readCachedToken( - host: string, - clientId: string, -): Promise { - const data = await readCacheFile(); - const key = cacheKey(host, clientId); - const entry = data[key]; - if (!entry) return null; - - const expiresAt = new Date(entry.expires_at).getTime(); - if ( - !Number.isFinite(expiresAt) || - expiresAt - Date.now() <= EXPIRY_SAFETY_MS - ) { - delete data[key]; - await writeCacheFile(data); - return null; - } - - return entry; -} - -export async function writeCachedToken(token: CachedToken): Promise { - const data = await readCacheFile(); - data[cacheKey(token.sentry_host, token.client_id)] = token; - await writeCacheFile(data); -} - -export async function clearCachedToken( - host: string, - clientId: string, -): Promise { - const data = await readCacheFile(); - const key = cacheKey(host, clientId); - if (key in data) { - delete data[key]; - await writeCacheFile(data); - } -} diff --git a/packages/mcp-server/src/auth/types.test.ts b/packages/mcp-server/src/auth/types.test.ts deleted file mode 100644 index fd0c26001..000000000 --- a/packages/mcp-server/src/auth/types.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { TokenResponseSchema, toCachedToken } from "./types"; - -describe("TokenResponseSchema", () => { - it("accepts upstream token payloads without email validation", () => { - const token = TokenResponseSchema.parse({ - access_token: "access-token", - refresh_token: "refresh-token", - token_type: "bearer", - expires_in: 3600, - expires_at: "2026-04-13T13:00:00.000Z", - user: { - email: "github-sso-user", - id: "123", - name: null, - }, - scope: "org:read", - }); - - expect(token.refresh_token).toBe("refresh-token"); - expect(token.expires_in).toBe(3600); - expect(token.expires_at).toBe("2026-04-13T13:00:00.000Z"); - expect(token.user.email).toBe("github-sso-user"); - }); -}); - -describe("toCachedToken", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("stores the upstream absolute expiry and a fallback user label", () => { - const cached = toCachedToken( - { - access_token: "access-token", - refresh_token: "refresh-token", - token_type: "bearer", - expires_in: 3600, - expires_at: "2026-04-13T13:00:00.000Z", - user: { - email: "github-sso-user", - id: "123", - name: null, - }, - scope: "org:read", - }, - "sentry.io", - "client-id", - ); - - expect(cached).toEqual({ - access_token: "access-token", - refresh_token: "refresh-token", - expires_at: "2026-04-13T13:00:00.000Z", - sentry_host: "sentry.io", - client_id: "client-id", - user_email: "github-sso-user", - scope: "org:read", - }); - }); -}); diff --git a/packages/mcp-server/src/auth/types.ts b/packages/mcp-server/src/auth/types.ts deleted file mode 100644 index ce5ec4911..000000000 --- a/packages/mcp-server/src/auth/types.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from "zod"; - -/** - * Response from Sentry's device code endpoint (POST /oauth/device/code/). - * See: https://docs.sentry.io/api/auth/#device-authorization-flow - */ -export const DeviceCodeResponseSchema = z.object({ - device_code: z.string(), - user_code: z.string(), - verification_uri: z.string().url(), - verification_uri_complete: z.string().url(), - interval: z.number().int().positive(), - expires_in: z.number().int().positive(), -}); - -export type DeviceCodeResponse = z.infer; - -/** - * Error response during device code token polling. - */ -export const DeviceCodeErrorSchema = z.object({ - error: z.string(), - error_description: z.string().optional(), -}); - -/** - * Successful token response from Sentry's token endpoint. - * Mirrors the shape used by the cloudflare OAuth callback. - */ -export const TokenResponseSchema = z.object({ - access_token: z.string(), - refresh_token: z.string(), - token_type: z.string(), - expires_in: z.number(), - expires_at: z.string().datetime(), - user: z.object({ - email: z.string().nullable().optional(), - id: z.string(), - name: z.string().nullable(), - }), - scope: z.string(), -}); - -export type TokenResponse = z.infer; - -/** - * Shape of a cached token entry stored on disk. - */ -export type CachedToken = { - access_token: string; - refresh_token: string; - expires_at: string; - sentry_host: string; - client_id: string; - // Historical name retained for cache compatibility; this stores a user label, - // not necessarily an email address. - user_email: string; - scope: string; -}; - -export function getTokenUserLabel(tokenResponse: TokenResponse): string { - return ( - tokenResponse.user.name ?? tokenResponse.user.email ?? tokenResponse.user.id - ); -} - -export function toCachedToken( - tokenResponse: TokenResponse, - sentryHost: string, - clientId: string, -): CachedToken { - return { - access_token: tokenResponse.access_token, - refresh_token: tokenResponse.refresh_token, - expires_at: tokenResponse.expires_at, - sentry_host: sentryHost, - client_id: clientId, - user_email: getTokenUserLabel(tokenResponse), - scope: tokenResponse.scope, - }; -} diff --git a/packages/mcp-server/src/cli/commands/auth.test.ts b/packages/mcp-server/src/cli/commands/auth.test.ts deleted file mode 100644 index e29a99de4..000000000 --- a/packages/mcp-server/src/cli/commands/auth.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { parseFlag } from "./auth"; - -describe("parseFlag", () => { - it("parses --flag=value form", () => { - expect(parseFlag(["--host=sentry.example.com"], "host")).toBe( - "sentry.example.com", - ); - }); - - it("parses --flag value form", () => { - expect(parseFlag(["--host", "sentry.example.com"], "host")).toBe( - "sentry.example.com", - ); - }); - - it("returns undefined when flag is absent", () => { - expect(parseFlag(["--other=foo"], "host")).toBeUndefined(); - }); - - it("returns undefined when --flag is last arg with no value", () => { - expect(parseFlag(["--host"], "host")).toBeUndefined(); - }); - - it("prefers first occurrence", () => { - expect(parseFlag(["--host=first", "--host=second"], "host")).toBe("first"); - }); - - it("works with --url flag", () => { - expect(parseFlag(["--url", "https://sentry.example.com"], "url")).toBe( - "https://sentry.example.com", - ); - }); - - it("does not match partial flag names", () => { - expect(parseFlag(["--hostname=foo"], "host")).toBeUndefined(); - }); -}); diff --git a/packages/mcp-server/src/cli/commands/auth.ts b/packages/mcp-server/src/cli/commands/auth.ts deleted file mode 100644 index e69b9cf33..000000000 --- a/packages/mcp-server/src/cli/commands/auth.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; -import { parseEnv } from "../parse"; -import { resolveHost } from "../resolve"; -import { DEFAULT_SENTRY_CLIENT_ID, OAUTH_HOST } from "../../auth/constants"; -import { authenticate } from "../../auth/device-code-flow"; -import { - readCachedToken, - writeCachedToken, - clearCachedToken, -} from "../../auth/token-cache"; -import { toCachedToken } from "../../auth/types"; - -type AuthContext = { - sentryHost: string; - clientId: string; -}; - -export function parseFlag(argv: string[], name: string): string | undefined { - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg.startsWith(`--${name}=`)) { - return arg.slice(`--${name}=`.length); - } - if (arg === `--${name}` && i + 1 < argv.length) { - return argv[i + 1]; - } - } - return undefined; -} - -function resolveAuthContext(argv: string[]): AuthContext { - const env = parseEnv(process.env); - - // Match the precedence of the server's merge() + finalize() path: - // merged url (CLI --url ?? env SENTRY_URL) beats merged host (CLI --host ?? env SENTRY_HOST) - const url = parseFlag(argv, "url") ?? env.url; - const host = parseFlag(argv, "host") ?? env.host; - const sentryHost = resolveHost(url, host); - - return { sentryHost, clientId: env.clientId || DEFAULT_SENTRY_CLIENT_ID }; -} - -async function login(argv: string[]): Promise { - const { sentryHost, clientId } = resolveAuthContext(argv); - - if (!isSentryHost(sentryHost)) { - console.error( - "Error: Device code authentication is only supported for sentry.io.", - ); - process.exit(1); - } - - try { - const tokenResponse = await authenticate({ clientId, host: OAUTH_HOST }); - await writeCachedToken(toCachedToken(tokenResponse, sentryHost, clientId)); - } catch (err) { - console.error( - err instanceof Error - ? err.message - : `Authentication failed: ${String(err)}`, - ); - process.exit(1); - } -} - -async function logout(argv: string[]): Promise { - const { sentryHost, clientId } = resolveAuthContext(argv); - - const cached = await readCachedToken(sentryHost, clientId); - if (cached) { - await clearCachedToken(sentryHost, clientId); - console.log(`Logged out (removed cached token for ${cached.user_email}).`); - } else { - console.log("No cached authentication found."); - } -} - -async function status(argv: string[]): Promise { - const { sentryHost, clientId } = resolveAuthContext(argv); - - const cached = await readCachedToken(sentryHost, clientId); - if (cached) { - const expiresAt = new Date(cached.expires_at); - console.log(`Authenticated as ${cached.user_email}`); - console.log(` Host: ${cached.sentry_host}`); - console.log(` Scopes: ${cached.scope}`); - console.log(` Expires: ${expiresAt.toLocaleString()}`); - } else { - console.log("Not authenticated. Run `sentry-mcp auth login` to sign in."); - } -} - -export async function authCommand(argv: string[]): Promise { - const sub = argv[0] ?? "login"; - const rest = argv.slice(1); - - switch (sub) { - case "login": - return await login(rest); - case "logout": - return await logout(rest); - case "status": - return await status(rest); - default: - console.error(`Unknown auth command: ${sub}`); - console.error("Available: auth login, auth logout, auth status"); - process.exit(1); - } -} diff --git a/packages/mcp-server/src/cli/output.ts b/packages/mcp-server/src/cli/output.ts deleted file mode 100644 index bdb71d834..000000000 --- a/packages/mcp-server/src/cli/output.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function printCliLine(message: string): void { - console.log(message); -} diff --git a/packages/mcp-server/src/cli/parse.test.ts b/packages/mcp-server/src/cli/parse.test.ts deleted file mode 100644 index dfe8985a5..000000000 --- a/packages/mcp-server/src/cli/parse.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { parseArgv, parseEnv, merge } from "./parse"; - -describe("cli/parseArgv", () => { - it("parses known flags and short aliases", () => { - const parsed = parseArgv([ - "--access-token=tok", - "--host=sentry.io", - "--insecure-http", - "--url=https://example.com", - "--mcp-url=https://mcp.example.com", - "--sentry-dsn=dsn", - "--openai-base-url=https://api.example.com/v1", - "--skills=inspect,triage", - "--all-skills", - "-h", - "-v", - ]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.host).toBe("sentry.io"); - expect(parsed.insecureHttp).toBe(true); - expect(parsed.url).toBe("https://example.com"); - expect(parsed.mcpUrl).toBe("https://mcp.example.com"); - expect(parsed.sentryDsn).toBe("dsn"); - expect(parsed.openaiBaseUrl).toBe("https://api.example.com/v1"); - expect(parsed.skills).toBe("inspect,triage"); - expect(parsed.allSkills).toBe(true); - expect(parsed.help).toBe(true); - expect(parsed.version).toBe(true); - expect(parsed.unknownArgs).toEqual([]); - }); - - it("parses skills flags", () => { - const parsed = parseArgv(["--access-token=tok", "--skills=inspect,triage"]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.skills).toBe("inspect,triage"); - }); - - it("parses --disable-skills", () => { - const parsed = parseArgv(["--access-token=tok", "--disable-skills=seer"]); - expect(parsed.disableSkills).toBe("seer"); - }); - - it("parses --all-skills", () => { - const parsed = parseArgv(["--access-token=tok", "--all-skills"]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.allSkills).toBe(true); - }); - - it("parses --insecure-http", () => { - const parsed = parseArgv([ - "--access-token=tok", - "--host=sentry.internal:9000", - "--insecure-http", - ]); - expect(parsed.host).toBe("sentry.internal:9000"); - expect(parsed.insecureHttp).toBe(true); - }); - - it("parses --agent-provider=azure-openai", () => { - const parsed = parseArgv([ - "--access-token=tok", - "--agent-provider=azure-openai", - ]); - expect(parsed.agentProvider).toBe("azure-openai"); - }); - - it("parses --agent-provider=openrouter", () => { - const parsed = parseArgv([ - "--access-token=tok", - "--agent-provider=openrouter", - ]); - expect(parsed.agentProvider).toBe("openrouter"); - }); - - it("collects unknown args", () => { - const parsed = parseArgv(["--unknown", "--another=1"]); - expect(parsed.unknownArgs.length).toBeGreaterThan(0); - }); -}); - -describe("cli/parseEnv", () => { - it("parses environment variables including skills", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - SENTRY_HOST: "envhost", - MCP_URL: "envmcp", - SENTRY_DSN: "envdsn", - MCP_SKILLS: "inspect,triage", - } as any); - expect(env.accessToken).toBe("envtok"); - expect(env.host).toBe("envhost"); - expect(env.mcpUrl).toBe("envmcp"); - expect(env.sentryDsn).toBe("envdsn"); - expect(env.skills).toBe("inspect,triage"); - }); - - it("reads MCP_DISABLE_SKILLS", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_DISABLE_SKILLS: "seer", - } as any); - expect(env.disableSkills).toBe("seer"); - }); -}); - -describe("cli/merge", () => { - it("applies precedence: CLI over env", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - SENTRY_HOST: "envhost", - MCP_URL: "envmcp", - SENTRY_DSN: "envdsn", - } as any); - const cli = parseArgv([ - "--access-token=clitok", - "--host=clihost", - "--insecure-http", - "--mcp-url=climcp", - "--sentry-dsn=clidsn", - "--openai-base-url=https://api.cli/v1", - ]); - const merged = merge(cli, env); - expect(merged.accessToken).toBe("clitok"); - expect(merged.host).toBe("clihost"); - expect(merged.insecureHttp).toBe(true); - expect(merged.mcpUrl).toBe("climcp"); - expect(merged.sentryDsn).toBe("clidsn"); - expect(merged.openaiBaseUrl).toBe("https://api.cli/v1"); - }); - - it("applies precedence for skills: CLI over env", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect", - } as any); - const cli = parseArgv(["--access-token=clitok", "--skills=inspect,triage"]); - const merged = merge(cli, env); - expect(merged.skills).toBe("inspect,triage"); - }); - - it("falls back to env when CLI skills not provided", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect,triage", - } as any); - const cli = parseArgv(["--access-token=clitok"]); - const merged = merge(cli, env); - expect(merged.skills).toBe("inspect,triage"); - }); - - it("lets CLI allSkills override env skills", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect", - } as any); - const cli = parseArgv(["--access-token=clitok", "--all-skills"]); - const merged = merge(cli, env); - expect(merged.allSkills).toBe(true); - expect(merged.skills).toBeUndefined(); - }); - - it("applies precedence for disableSkills: CLI over env", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_DISABLE_SKILLS: "docs", - } as any); - const cli = parseArgv(["--access-token=clitok", "--disable-skills=seer"]); - const merged = merge(cli, env); - expect(merged.disableSkills).toBe("seer"); - }); - - it("falls back to env when CLI disableSkills not provided", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_DISABLE_SKILLS: "seer", - } as any); - const cli = parseArgv(["--access-token=clitok"]); - const merged = merge(cli, env); - expect(merged.disableSkills).toBe("seer"); - }); - - it("applies precedence for agentProvider: CLI over env", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - EMBEDDED_AGENT_PROVIDER: "openai", - } as any); - const cli = parseArgv([ - "--access-token=clitok", - "--agent-provider=azure-openai", - ]); - const merged = merge(cli, env); - expect(merged.agentProvider).toBe("azure-openai"); - }); -}); diff --git a/packages/mcp-server/src/cli/parse.ts b/packages/mcp-server/src/cli/parse.ts deleted file mode 100644 index 85b379e80..000000000 --- a/packages/mcp-server/src/cli/parse.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { parseArgs } from "node:util"; -import type { CliArgs, EnvArgs, MergedArgs } from "./types"; - -export function parseArgv(argv: string[]): CliArgs { - const options = { - "access-token": { type: "string" as const }, - host: { type: "string" as const }, - "insecure-http": { type: "boolean" as const }, - url: { type: "string" as const }, - "mcp-url": { type: "string" as const }, - "sentry-dsn": { type: "string" as const }, - "openai-base-url": { type: "string" as const }, - "openai-model": { type: "string" as const }, - "anthropic-base-url": { type: "string" as const }, - "anthropic-model": { type: "string" as const }, - "agent-provider": { type: "string" as const }, - "organization-slug": { type: "string" as const }, - "project-slug": { type: "string" as const }, - skills: { type: "string" as const }, - "all-skills": { type: "boolean" as const }, - "disable-skills": { type: "string" as const }, - experimental: { type: "boolean" as const }, - help: { type: "boolean" as const, short: "h" as const }, - version: { type: "boolean" as const, short: "v" as const }, - }; - - const { values, positionals, tokens } = parseArgs({ - args: argv, - options, - allowPositionals: false, - strict: false, - tokens: true, - }); - - const knownLong = new Set(Object.keys(options)); - const knownShort = new Set( - Object.values(options) - .map((o) => ("short" in o ? (o.short as string | undefined) : undefined)) - .filter(Boolean) as string[], - ); - - const unknownArgs: string[] = []; - for (const t of (tokens as any[]) || []) { - if (t.kind === "option") { - const name = t.name as string | undefined; - if (name && !(knownLong.has(name) || knownShort.has(name))) { - unknownArgs.push((t.raw as string) ?? `--${name}`); - } - } else if (t.kind === "positional") { - unknownArgs.push((t.raw as string) ?? String(t.value ?? "")); - } - } - - return { - accessToken: values["access-token"] as string | undefined, - host: values.host as string | undefined, - insecureHttp: values["insecure-http"] === true, - url: values.url as string | undefined, - mcpUrl: values["mcp-url"] as string | undefined, - sentryDsn: values["sentry-dsn"] as string | undefined, - openaiBaseUrl: values["openai-base-url"] as string | undefined, - openaiModel: values["openai-model"] as string | undefined, - anthropicBaseUrl: values["anthropic-base-url"] as string | undefined, - anthropicModel: values["anthropic-model"] as string | undefined, - agentProvider: values["agent-provider"] as string | undefined, - organizationSlug: values["organization-slug"] as string | undefined, - projectSlug: values["project-slug"] as string | undefined, - skills: values.skills as string | undefined, - allSkills: values["all-skills"] === true, - disableSkills: values["disable-skills"] as string | undefined, - experimental: values.experimental === true, - help: (values.help as boolean | undefined) === true, - version: (values.version as boolean | undefined) === true, - unknownArgs: - unknownArgs.length > 0 ? unknownArgs : (positionals as string[]) || [], - }; -} - -export function parseEnv(env: NodeJS.ProcessEnv): EnvArgs { - const fromEnv: EnvArgs = {}; - if (env.SENTRY_ACCESS_TOKEN) fromEnv.accessToken = env.SENTRY_ACCESS_TOKEN; - if (env.SENTRY_URL) fromEnv.url = env.SENTRY_URL; - if (env.SENTRY_HOST) fromEnv.host = env.SENTRY_HOST; - if (env.MCP_URL) fromEnv.mcpUrl = env.MCP_URL; - if (env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN) - fromEnv.sentryDsn = env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN; - - if (env.OPENAI_MODEL) fromEnv.openaiModel = env.OPENAI_MODEL; - if (env.ANTHROPIC_MODEL) fromEnv.anthropicModel = env.ANTHROPIC_MODEL; - if (env.EMBEDDED_AGENT_PROVIDER) - fromEnv.agentProvider = env.EMBEDDED_AGENT_PROVIDER; - if (env.SENTRY_CLIENT_ID) fromEnv.clientId = env.SENTRY_CLIENT_ID; - if (env.MCP_SKILLS) fromEnv.skills = env.MCP_SKILLS; - if (env.MCP_DISABLE_SKILLS) fromEnv.disableSkills = env.MCP_DISABLE_SKILLS; - return fromEnv; -} - -export function merge(cli: CliArgs, env: EnvArgs): MergedArgs { - // CLI wins over env - return { - accessToken: cli.accessToken ?? env.accessToken, - // If CLI provided url/host, prefer those; else fall back to env - url: cli.url ?? env.url, - host: cli.host ?? env.host, - insecureHttp: cli.insecureHttp === true, - mcpUrl: cli.mcpUrl ?? env.mcpUrl, - sentryDsn: cli.sentryDsn ?? env.sentryDsn, - openaiBaseUrl: cli.openaiBaseUrl, - openaiModel: cli.openaiModel ?? env.openaiModel, - anthropicBaseUrl: cli.anthropicBaseUrl, - anthropicModel: cli.anthropicModel ?? env.anthropicModel, - agentProvider: cli.agentProvider ?? env.agentProvider, - clientId: env.clientId, - // Skills precedence: CLI skills override env; --all-skills also overrides env skills. - skills: cli.skills ?? (cli.allSkills ? undefined : env.skills), - allSkills: cli.allSkills === true, - disableSkills: cli.disableSkills ?? env.disableSkills, - experimental: cli.experimental === true, - organizationSlug: cli.organizationSlug, - projectSlug: cli.projectSlug, - help: cli.help === true, - version: cli.version === true, - unknownArgs: cli.unknownArgs, - }; -} diff --git a/packages/mcp-server/src/cli/resolve.ts b/packages/mcp-server/src/cli/resolve.ts deleted file mode 100644 index d51b0baaa..000000000 --- a/packages/mcp-server/src/cli/resolve.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - ACTIVE_SKILLS, - ALL_SKILLS, - parseSkills, - type Skill, -} from "@sentry/mcp-core/skills"; -import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; -import { - validateAndParseSentryUrlThrows, - validateOpenAiBaseUrlThrows, - validateSentryHostThrows, -} from "@sentry/mcp-core/utils/url-utils"; -import { DEFAULT_SENTRY_CLIENT_ID } from "../auth/constants"; -import type { MergedArgs, PartiallyResolvedConfig } from "./types"; - -export function formatInvalidSkills( - invalid: string[], - source?: string, -): string { - const prefix = source ? `${source} provided` : "Invalid skills provided"; - return `Error: ${prefix}: ${invalid.join(", ")}\nAvailable skills: ${ALL_SKILLS.join(", ")}`; -} - -/** - * Resolves the Sentry host from url/host inputs with validation. - * Used by both the server flow (finalize) and auth commands (resolveAuthContext) - * to ensure cache keys always match. - */ -export function resolveHost(url?: string, host?: string): string { - if (url) { - return validateAndParseSentryUrlThrows(url); - } - if (host) { - validateSentryHostThrows(host); - return host; - } - return "sentry.io"; -} - -export function finalize(input: MergedArgs): PartiallyResolvedConfig { - if (input.insecureHttp && input.url) { - throw new Error( - "Error: --insecure-http cannot be used with --url or SENTRY_URL. Use --host for insecure self-hosted instances.", - ); - } - - const sentryHost = resolveHost(input.url, input.host); - - if (input.insecureHttp && isSentryHost(sentryHost)) { - throw new Error( - "Error: --insecure-http is only supported for self-hosted Sentry hosts.", - ); - } - - const sentryProtocol = input.insecureHttp ? "http" : "https"; - - // Skills resolution - // - // IMPORTANT: stdio (CLI) intentionally defaults to all active skills when no --skills flag is provided - // - // This differs from the OAuth flow, which requires explicit user selection: - // - stdio/CLI: Non-interactive, defaults to all non-deprecated skills - // - OAuth: Interactive, requires user to explicitly select skills (with sensible defaults pre-checked) - // - // Rationale: - // We don't want the MCP to break if users don't specify skills. stdio is typically used in - // local development and CI/CD environments where maximum access by default is expected. - // OAuth is used in multi-tenant hosted environments where users should consciously grant - // permissions on a per-app basis. - // - // For OAuth validation that enforces minimum 1 skill selection, see: - // packages/mcp-cloudflare/src/server/oauth/routes/callback.ts (lines 234-248) - // - let finalSkills: Set; - if (input.skills && input.allSkills) { - throw new Error("Error: --all-skills cannot be combined with --skills."); - } - - if (input.allSkills) { - finalSkills = new Set(ACTIVE_SKILLS); - } else if (input.skills) { - // Override: use only the specified skills - const { valid, invalid } = parseSkills(input.skills); - if (invalid.length > 0) { - throw new Error(formatInvalidSkills(invalid)); - } - if (valid.size === 0) { - throw new Error("Error: Invalid skills provided. No valid skills found."); - } - finalSkills = valid; - } else { - // Default: grant all active skills when no flag is provided (see comment block above for rationale) - finalSkills = new Set(ACTIVE_SKILLS); - - // Seer is a sentry.io service. Self-hosted Sentry ships without it, and its - // autofix endpoints answer 500 when the Seer backend is not configured, so - // every `get_issue_details` call (which fetches autofix state) and every - // `analyze_issue_with_seer` poll loop would just generate server errors. - // Leave Seer out of the implicit default on self-hosted hosts; users who - // do run Seer can still opt in with --skills=seer or --all-skills. - if (!isSentryHost(sentryHost)) { - finalSkills.delete("seer"); - } - } - - // Disable-skills: remove specific skills from the active set - if (input.disableSkills) { - const { valid: skillsToDisable, invalid } = parseSkills( - input.disableSkills, - ); - if (invalid.length > 0) { - throw new Error(formatInvalidSkills(invalid, "--disable-skills")); - } - for (const skill of skillsToDisable) { - finalSkills.delete(skill); - } - if (finalSkills.size === 0) { - throw new Error( - "Error: All skills have been disabled. At least one skill must remain enabled.", - ); - } - } - - const resolvedOpenAiBaseUrl = input.openaiBaseUrl - ? validateOpenAiBaseUrlThrows(input.openaiBaseUrl) - : undefined; - - // Validate anthropic base URL if provided (same validation as OpenAI) - const resolvedAnthropicBaseUrl = input.anthropicBaseUrl - ? validateOpenAiBaseUrlThrows(input.anthropicBaseUrl) - : undefined; - - // Validate agent provider if explicitly set - let agentProvider: - | "openai" - | "azure-openai" - | "anthropic" - | "openrouter" - | undefined = undefined; - if (input.agentProvider) { - const provider = input.agentProvider.toLowerCase(); - if ( - provider !== "openai" && - provider !== "azure-openai" && - provider !== "anthropic" && - provider !== "openrouter" - ) { - throw new Error( - `Error: Invalid agent provider "${input.agentProvider}". Must be "openai", "azure-openai", "anthropic", or "openrouter".`, - ); - } - agentProvider = provider; - } - - return { - accessToken: input.accessToken, - clientId: input.clientId || DEFAULT_SENTRY_CLIENT_ID, - sentryHost, - sentryProtocol, - mcpUrl: input.mcpUrl, - sentryDsn: input.sentryDsn, - openaiBaseUrl: resolvedOpenAiBaseUrl, - openaiModel: input.openaiModel, - anthropicBaseUrl: resolvedAnthropicBaseUrl, - anthropicModel: input.anthropicModel, - agentProvider, - finalSkills, - organizationSlug: input.organizationSlug, - projectSlug: input.projectSlug, - }; -} diff --git a/packages/mcp-server/src/cli/types.ts b/packages/mcp-server/src/cli/types.ts deleted file mode 100644 index 215d24941..000000000 --- a/packages/mcp-server/src/cli/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { SentryProtocol } from "@sentry/mcp-core/types"; -import type { Skill } from "@sentry/mcp-core/skills"; - -export type CliArgs = { - accessToken?: string; - host?: string; - insecureHttp?: boolean; - url?: string; - mcpUrl?: string; - sentryDsn?: string; - openaiBaseUrl?: string; - openaiModel?: string; - anthropicBaseUrl?: string; - anthropicModel?: string; - agentProvider?: string; - skills?: string; - allSkills?: boolean; - disableSkills?: string; - experimental?: boolean; - organizationSlug?: string; - projectSlug?: string; - help?: boolean; - version?: boolean; - unknownArgs: string[]; -}; - -export type EnvArgs = { - accessToken?: string; - host?: string; // parsed from SENTRY_HOST or SENTRY_URL (raw value) - url?: string; // raw URL if provided (SENTRY_URL) - mcpUrl?: string; - sentryDsn?: string; - openaiModel?: string; - anthropicModel?: string; - agentProvider?: string; - clientId?: string; - skills?: string; - disableSkills?: string; -}; - -export type MergedArgs = { - accessToken?: string; - host?: string; - insecureHttp?: boolean; - url?: string; - mcpUrl?: string; - sentryDsn?: string; - openaiBaseUrl?: string; - openaiModel?: string; - anthropicBaseUrl?: string; - anthropicModel?: string; - agentProvider?: string; - clientId?: string; // env-only, carried from EnvArgs - skills?: string; - allSkills?: boolean; - disableSkills?: string; - experimental?: boolean; - organizationSlug?: string; - projectSlug?: string; - help?: boolean; - version?: boolean; - unknownArgs: string[]; -}; - -export type CliAgentProvider = - | "openai" - | "azure-openai" - | "anthropic" - | "openrouter"; - -/** - * Partially resolved config — accessToken may not yet be available - * (will be resolved via device code flow or cache). - */ -export type PartiallyResolvedConfig = { - accessToken?: string; - clientId: string; - sentryHost: string; - sentryProtocol: SentryProtocol; - mcpUrl?: string; - sentryDsn?: string; - openaiBaseUrl?: string; - openaiModel?: string; - anthropicBaseUrl?: string; - anthropicModel?: string; - agentProvider?: CliAgentProvider; - /** Skills granted for this session (always populated by finalize()) */ - finalSkills: Set; - organizationSlug?: string; - projectSlug?: string; -}; - -export type ResolvedConfig = Omit & { - accessToken: string; -}; diff --git a/packages/mcp-server/src/cli/usage.test.ts b/packages/mcp-server/src/cli/usage.test.ts deleted file mode 100644 index 77a50e784..000000000 --- a/packages/mcp-server/src/cli/usage.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index c49fa4e6d..000000000 --- a/packages/mcp-server/src/cli/usage.ts +++ /dev/null @@ -1,123 +0,0 @@ -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] - -Commands: - auth login Authenticate via device code flow (sentry.io only) - auth logout Clear cached authentication - auth status Show current authentication state - -Authentication: - --access-token Sentry User Auth Token with API access - Optional for sentry.io (device code flow is used if omitted) - Required for self-hosted instances - -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: - SENTRY_ACCESS_TOKEN Sentry auth token (alternative to --access-token) - SENTRY_CLIENT_ID Override OAuth client ID for device code flow - 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} # device code auth (sentry.io only) - ${packageName} --access-token=TOKEN - ${packageName} --access-token=TOKEN --all-skills - ${packageName} --access-token=TOKEN --skills=inspect,triage - ${packageName} --access-token=TOKEN --host=sentry.example.com - ${packageName} --access-token=TOKEN --host=sentry.internal:9000 --insecure-http - ${packageName} --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer # self-hosted with Seer - ${packageName} --access-token=TOKEN --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ - ${packageName} --access-token=TOKEN --agent-provider=anthropic - ${packageName} --access-token=TOKEN --agent-provider=openrouter`; -} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts deleted file mode 100644 index cd8a1d608..000000000 --- a/packages/mcp-server/src/index.ts +++ /dev/null @@ -1,461 +0,0 @@ -#!/usr/bin/env node - -/** - * Main CLI entry point for the Sentry MCP server. - * - * Handles command-line argument parsing, environment configuration, Sentry - * initialization, and starts the MCP server with stdio transport. Supports - * device code authentication for sentry.io when no access token is provided. - * - * Subcommands: - * auth [login] — Force device code authentication - * auth logout — Clear cached authentication - * auth status — Show current authentication state - * - * @example CLI Usage - * ```bash - * npx @sentry/mcp-server --access-token=TOKEN --host=sentry.io - * npx @sentry/mcp-server auth login - * npx @sentry/mcp-server auth logout - * ``` - */ - -import { pathToFileURL } from "node:url"; - -import { - getAgentProvider, - getResolvedProviderType, - setAgentProvider, - setProviderBaseUrls, -} from "@sentry/mcp-core/internal/agents/provider-factory"; -import { buildServer } from "@sentry/mcp-core/server"; -import { SKILLS } from "@sentry/mcp-core/skills"; -import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; -import { LIB_VERSION } from "@sentry/mcp-core/version"; -import * as Sentry from "@sentry/node"; -import { resolveAccessToken } from "./auth/resolve-token"; -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 defaultPackageName = "@sentry/mcp-server"; -const allSkills = Object.keys(SKILLS) as ReadonlyArray< - (typeof SKILLS)[keyof typeof SKILLS]["id"] ->; - -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, - }); - - 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; - } - - const cli = parseArgv(rawArgs); - if (cli.help) { - printCliLine(usageText); - process.exit(0); - } - if (cli.version) { - printCliLine(`${packageName} ${LIB_VERSION}`); - process.exit(0); - } - if (cli.unknownArgs.length > 0) { - die(new Error(`Error: Invalid argument(s): ${cli.unknownArgs.join(", ")}`)); - } - - const env = parseEnv(options.environment ?? process.env); - const partialCfg = (() => { - try { - return finalize(merge(cli, env)); - } catch (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 ( - options.resolveAccessToken - ? options.resolveAccessToken(partialCfg).then((accessToken) => ({ - ...partialCfg, - accessToken, - })) - : resolveAccessToken(partialCfg) - ).catch((err) => { - die(err); - }); - - // Configure embedded agent provider - if (cfg.agentProvider) { - setAgentProvider(cfg.agentProvider); - } - setProviderBaseUrls({ - openaiBaseUrl: cfg.openaiBaseUrl, - anthropicBaseUrl: cfg.anthropicBaseUrl, - }); - if (cfg.openaiModel) { - process.env.OPENAI_MODEL = cfg.openaiModel; - } - if (cfg.anthropicModel) { - process.env.ANTHROPIC_MODEL = cfg.anthropicModel; - } - - // Helper functions for provider status messages - function hasProviderConflict(): boolean { - const providerKeyCount = [ - process.env.ANTHROPIC_API_KEY, - process.env.OPENAI_API_KEY, - process.env.OPENROUTER_API_KEY, - ].filter(Boolean).length; - const hasExplicitProvider = - cfg.agentProvider || process.env.EMBEDDED_AGENT_PROVIDER; - return providerKeyCount > 1 && !hasExplicitProvider; - } - - function getConfiguredProvider(): string | undefined { - return ( - cfg.agentProvider || process.env.EMBEDDED_AGENT_PROVIDER?.toLowerCase() - ); - } - - function hasProviderMismatch(): { - mismatch: boolean; - configured?: string; - availableKey?: string; - } { - const configured = getConfiguredProvider(); - if (!configured) return { mismatch: false }; - - const hasAnthropic = Boolean(process.env.ANTHROPIC_API_KEY); - const hasOpenAI = Boolean(process.env.OPENAI_API_KEY); - const hasOpenRouter = Boolean(process.env.OPENROUTER_API_KEY); - - // Check if configured provider's key is missing but other key is present - if ( - (configured === "openai" || configured === "azure-openai") && - !hasOpenAI && - (hasAnthropic || hasOpenRouter) - ) { - return { - mismatch: true, - configured, - availableKey: hasOpenRouter - ? "OPENROUTER_API_KEY" - : "ANTHROPIC_API_KEY", - }; - } - if ( - configured === "anthropic" && - !hasAnthropic && - (hasOpenAI || hasOpenRouter) - ) { - return { - mismatch: true, - configured: "anthropic", - availableKey: hasOpenRouter ? "OPENROUTER_API_KEY" : "OPENAI_API_KEY", - }; - } - if ( - configured === "openrouter" && - !hasOpenRouter && - (hasOpenAI || hasAnthropic) - ) { - return { - mismatch: true, - configured: "openrouter", - availableKey: hasOpenAI ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY", - }; - } - - return { mismatch: false }; - } - - function getProviderSource(): string { - // Check CLI flag first (cli.agentProvider is only set by --agent-provider flag) - if (cli.agentProvider) return "explicitly configured"; - // Then check env var (process.env takes precedence over cfg since cfg merges both) - if (process.env.EMBEDDED_AGENT_PROVIDER) - return "from EMBEDDED_AGENT_PROVIDER"; - return "auto-detected"; - } - - // Check for LLM API keys and warn if none available - const resolvedProvider = getResolvedProviderType() as - | "openai" - | "azure-openai" - | "anthropic" - | "openrouter" - | undefined; - - if (!resolvedProvider) { - const mismatchInfo = hasProviderMismatch(); - let providerConfigError: string | undefined; - try { - getAgentProvider(); - } catch (error) { - providerConfigError = - error instanceof Error ? error.message : String(error); - } - - if (hasProviderConflict()) { - console.warn( - "Warning: Multiple LLM API keys are set, but no provider is explicitly configured.", - ); - console.warn( - "Please set EMBEDDED_AGENT_PROVIDER='openai', 'azure-openai', 'anthropic', or 'openrouter' to specify which provider to use.", - ); - console.warn( - "AI-powered search tools will be unavailable until a provider is selected.", - ); - } else if (mismatchInfo.mismatch) { - const expectedKey = - mismatchInfo.configured === "openai" || - mismatchInfo.configured === "azure-openai" - ? "OPENAI_API_KEY" - : mismatchInfo.configured === "openrouter" - ? "OPENROUTER_API_KEY" - : "ANTHROPIC_API_KEY"; - const configuredViaCliFlag = Boolean(cli.agentProvider); - const providerSetting = configuredViaCliFlag - ? `--agent-provider=${mismatchInfo.configured}` - : `EMBEDDED_AGENT_PROVIDER='${mismatchInfo.configured}'`; - const changeProviderHint = configuredViaCliFlag - ? "Change --agent-provider to match your available API key" - : "Change EMBEDDED_AGENT_PROVIDER to match your available API key"; - console.warn( - `Warning: ${providerSetting} but ${expectedKey} is not set.`, - ); - console.warn(`Found ${mismatchInfo.availableKey} instead. Either:`); - console.warn( - ` - Set ${expectedKey} to use the ${mismatchInfo.configured} provider, or`, - ); - console.warn(` - ${changeProviderHint}`); - console.warn( - "AI-powered search tools will be unavailable until this is resolved.", - ); - } else if ( - providerConfigError && - !providerConfigError.startsWith("No embedded agent provider configured") - ) { - console.warn(`Warning: ${providerConfigError}`); - console.warn( - "AI-powered search tools will be unavailable until this is resolved.", - ); - } else { - console.warn( - "Warning: No LLM API key found (OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY).", - ); - console.warn("AI-powered search tools will be unavailable."); - console.warn( - "Search tools still work with direct Sentry query syntax via the 'query' parameter.", - ); - } - console.warn(""); - } else { - const providerSource = getProviderSource(); - const providerLabel = getAgentProvider().label; - console.warn( - `Using ${providerLabel} for AI-powered search tools (${providerSource}).`, - ); - // Warn about auto-detection deprecation - if (providerSource === "auto-detected") { - console.warn( - "Deprecation warning: Auto-detection of LLM provider is deprecated.", - ); - console.warn( - `Please set EMBEDDED_AGENT_PROVIDER='${resolvedProvider}' explicitly.`, - ); - console.warn("Auto-detection will be removed in a future release."); - } - console.warn(""); - } - - const scopeContext = { - "app.server.version": LIB_VERSION, - "app.transport": "stdio", - "app.upstream.host": cfg.sentryHost, - "app.url.full": cfg.mcpUrl, - }; - - Sentry.init({ - dsn: cfg.sentryDsn, - tracesSampleRate: 1, - beforeSend: sentryBeforeSend, - initialScope: { - tags: { - ...scopeContext, - "app.server.mode.experimental": cli.experimental ? "true" : "false", - }, - // SDK v11 does not copy scope tags onto streamed spans. - attributes: { - ...scopeContext, - "app.server.mode.experimental": cli.experimental === true, - }, - }, - release: process.env.SENTRY_RELEASE, - integrations: [ - Sentry.consoleLoggingIntegration(), - Sentry.zodErrorsIntegration(), - Sentry.vercelAIIntegration({ - recordInputs: true, - recordOutputs: true, - }), - ], - environment: - process.env.SENTRY_ENVIRONMENT ?? - (process.env.NODE_ENV !== "production" ? "development" : "production"), - }); - - // Log experimental mode status - if (cli.experimental) { - console.warn( - "Experimental mode enabled: Forward-looking tool variants and experimental features are available.", - ); - console.warn(""); - } - - const SENTRY_TIMEOUT = 5000; // 5 seconds - - // Graceful shutdown handlers - async function shutdown(signal: string) { - console.error(`${signal} received, shutting down...`); - await Sentry.flush(SENTRY_TIMEOUT); - process.exit(0); - } - - process.on("SIGTERM", () => shutdown("SIGTERM")); - process.on("SIGINT", () => shutdown("SIGINT")); - - // If the client dies and closes all pipes, console.error can throw EPIPE - // inside these handlers. Without a guard, that recurses forever and burns CPU. - // See getsentry/sentry-mcp#1274. - let exiting = false; - function exitAfterError(error: unknown, label: string) { - if (exiting) { - process.exit(1); - return; - } - exiting = true; - - try { - console.error(label, error); - } catch { - // stderr may already be closed - } - - try { - Sentry.captureException(error); - } catch { - // reporting must not block exit - } - - // Don't depend on flush finishing — exit even if it hangs or rejects. - const timer = setTimeout(() => process.exit(1), SENTRY_TIMEOUT); - timer.unref(); - void Sentry.flush(SENTRY_TIMEOUT).finally(() => process.exit(1)); - } - - process.on("uncaughtException", (error) => { - exitAfterError(error, "Uncaught exception:"); - }); - - process.on("unhandledRejection", (reason) => { - exitAfterError(reason, "Unhandled rejection:"); - }); - - const context = { - accessToken: cfg.accessToken, - grantedSkills: cfg.finalSkills, - constraints: { - organizationSlug: cfg.organizationSlug ?? null, - projectSlug: cfg.projectSlug ?? null, - regionUrl: null, - }, - sentryHost: cfg.sentryHost, - sentryProtocol: cfg.sentryProtocol, - mcpUrl: cfg.mcpUrl, - 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 - // Use experimentalMode when --experimental flag is set (enables forward-looking variants) - const server = buildServer({ - context, - experimentalMode: cli.experimental, - }); - - startStdio(server, context).catch((err) => { - exitAfterError(err, "Server error:"); - }); -} - -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/packages/mcp-server/src/transports/stdio.ts b/packages/mcp-server/src/transports/stdio.ts deleted file mode 100644 index 643ac9148..000000000 --- a/packages/mcp-server/src/transports/stdio.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -/** - * Standard I/O Transport for MCP Server. - * - * Provides stdio-based communication for the Sentry MCP server, typically used - * when the server runs as a subprocess communicating via stdin/stdout pipes. - * - * @example Basic Usage - * ```typescript - * import { Server } from "@modelcontextprotocol/sdk/server/index.js"; - * import { startStdio } from "./transports/stdio.js"; - * - * const server = new Server(); - * const context = { - * accessToken: process.env.SENTRY_TOKEN, - * host: "sentry.io" - * }; - * - * await startStdio(server, context); - * ``` - */ -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { LIB_VERSION } from "@sentry/mcp-core/version"; -import * as Sentry from "@sentry/node"; - -export type StdioServerContext = { - sentryHost?: string; - mcpUrl?: string; - experimentalMode?: boolean; -}; - -function getStdioSpanAttributes( - context: StdioServerContext, -): Record { - const attributes: Record = { - "app.transport": "stdio", - "app.server.version": LIB_VERSION, - "app.server.mode.experimental": context.experimentalMode ?? false, - "network.transport": "pipe", - "service.version": LIB_VERSION, - }; - - if (context.sentryHost) { - attributes["app.upstream.host"] = context.sentryHost; - } - if (context.mcpUrl) { - attributes["app.url.full"] = context.mcpUrl; - } - - return attributes; -} - -/** - * Starts the MCP server with stdio transport and telemetry. - * - * Connects the server using stdio transport for process-based communication. - * Context is already captured in tool handler closures during buildServer(). - * All operations are wrapped in Sentry tracing for observability. - * - * @param server - Configured and instrumented MCP server instance (with context in closures) - * @param context - Context values used for telemetry attributes - * - * @example CLI Integration - * ```typescript - * import { buildServer } from "./server.js"; - * import { startStdio } from "./transports/stdio.js"; - * - * const context = { - * accessToken: userToken, - * sentryHost: "sentry.io", - * userId: "user-123", - * clientId: "cursor-ide", - * constraints: {} - * }; - * - * const server = buildServer({ context }); // Context captured in closures - * await startStdio(server, context); - * ``` - */ -export async function startStdio( - server: McpServer, - context: Context, -) { - await Sentry.startNewTrace(async () => { - return await Sentry.startSpan( - { - name: "mcp.server/stdio", - attributes: getStdioSpanAttributes(context), - }, - async () => { - // Context already captured in tool handler closures during buildServer() - const transport = new StdioServerTransport(); - await server.connect(transport); - }, - ); - }); -} diff --git a/packages/mcp-server/test/bundle.test.mjs b/packages/mcp-server/test/bundle.test.mjs new file mode 100644 index 000000000..22b54faab --- /dev/null +++ b/packages/mcp-server/test/bundle.test.mjs @@ -0,0 +1,130 @@ +/** + * Smoke tests for the built `dist/sentry-mcp.cjs`. Run `pnpm run bundle` first. + */ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, test } from "node:test"; + +const packageRoot = new URL("..", import.meta.url).pathname; +const bin = join(packageRoot, "dist/sentry-mcp.cjs"); +const manifest = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf-8"), +); +const { version } = manifest; + +const configDir = mkdtempSync(join(tmpdir(), "sentry-mcp-test-")); +after(() => rmSync(configDir, { recursive: true, force: true })); + +const env = { + PATH: process.env.PATH, + SENTRY_CONFIG_DIR: configDir, + SENTRY_CLI_NO_TELEMETRY: "1", +}; + +function run(args, extraEnv = {}) { + return spawnSync(process.execPath, [bin, ...args], { + encoding: "utf-8", + env: { ...env, ...extraEnv }, + timeout: 30_000, + }); +} + +test("the package has no runtime dependencies", () => { + assert.deepEqual(manifest.dependencies ?? {}, {}); + assert.deepEqual(manifest.files, ["dist/sentry-mcp.cjs"]); +}); + +test("--version prints the package version", () => { + const result = run(["--version"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), `sentry-mcp ${version}`); +}); + +test("--help names the standalone command", () => { + const result = run(["--help"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^Usage: sentry-mcp /); + assert.match(result.stdout, /`npx -y @sentry\/mcp-server auth login`/); +}); + +test("a client closing stdout early is a clean exit, not a crash", async () => { + const child = spawn(process.execPath, [bin, "--help"], { + stdio: ["ignore", "pipe", "pipe"], + env, + }); + child.stdout.destroy(); + const chunks = []; + child.stderr.on("data", (chunk) => chunks.push(chunk)); + const code = await new Promise((resolve) => child.on("exit", resolve)); + const stderr = Buffer.concat(chunks).toString(); + assert.equal(code, 0, stderr); + assert.doesNotMatch(stderr, /EPIPE/); +}); + +test("without credentials, points to its own auth login", () => { + const result = run([]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /npx -y @sentry\/mcp-server auth login/); + assert.doesNotMatch(result.stderr, /'sentry auth login'/); +}); + +test("auth subcommands other than login point to the CLI", () => { + const result = run(["auth", "status"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /npx -y sentry auth status/); +}); + +test("serves MCP over stdio with a static token", async () => { + const child = spawn(process.execPath, [bin], { + env: { ...env, SENTRY_ACCESS_TOKEN: "sntryu_test" }, + stdio: ["pipe", "pipe", "pipe"], + }); + const byId = new Map(); + let buffer = ""; + const responses = new Promise((resolve, reject) => { + child.on("error", reject); + child.stdout.on("data", (chunk) => { + buffer += chunk; + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + for (const message of parts + .filter(Boolean) + .map((line) => JSON.parse(line))) { + if (message.id !== undefined) { + byId.set(message.id, message); + } + } + if (byId.has(1) && byId.has(2)) { + resolve([byId.get(1), byId.get(2)]); + } + }); + setTimeout(() => reject(new Error("no MCP response")), 20_000).unref(); + }); + + const send = (message) => + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); + send({ + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "bundle-test", version: "1.0.0" }, + }, + }); + send({ method: "notifications/initialized" }); + send({ id: 2, method: "tools/list" }); + + try { + const [initialize, toolsList] = await responses; + assert.equal(initialize.id, 1); + assert.ok(initialize.result?.serverInfo, JSON.stringify(initialize)); + assert.equal(toolsList.id, 2); + assert.ok(toolsList.result?.tools.length > 0, JSON.stringify(toolsList)); + } finally { + child.kill(); + } +}); diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json deleted file mode 100644 index 07eb7e9f6..000000000 --- a/packages/mcp-server/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@sentry/mcp-server-tsconfig/tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/packages/mcp-server/tsdown.config.ts b/packages/mcp-server/tsdown.config.ts deleted file mode 100644 index e52edf498..000000000 --- a/packages/mcp-server/tsdown.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "tsdown"; - -export default defineConfig({ - entry: ["src/**/*.ts", "!src/**/*.test.ts"], - format: ["cjs", "esm"], - dts: true, - sourcemap: true, - clean: true, - external: [ - // Only mark test-only packages as external - "@sentry/mcp-server-mocks", - // Everything else (including @sentry/mcp-core) will be bundled - ], - env: { - SENTRY_ENVIRONMENT: "stdio", - npm_package_version: "{{version}}", - }, -}); diff --git a/packages/mcp-test-client/README.md b/packages/mcp-test-client/README.md index cbbd59c3f..29487541e 100644 --- a/packages/mcp-test-client/README.md +++ b/packages/mcp-test-client/README.md @@ -126,11 +126,9 @@ SENTRY_ACCESS_TOKEN=your_token pnpm mcp-test-client --transport stdio pnpm mcp-test-client --transport stdio --list-tools ``` -When no token is provided in stdio mode: - -- `sentry.io` uses cached auth if available -- `sentry.io` starts device-code auth when running interactively -- non-interactive runs fail with the same auth error as the stdio server +Stdio mode runs the workspace CLI's `sentry mcp`. When no token is provided, +it uses the `sentry auth login` session; non-interactive runs without one fail +with the same auth error as `sentry mcp`. ### Interactive Mode (Default) @@ -202,7 +200,7 @@ pnpm typecheck If you see "Failed to connect to MCP server": -1. Ensure the mcp-server package is built +1. Ensure `@sentry/mcp-core` is built (`pnpm --filter @sentry/mcp-core build`) 2. Check that your access token is valid 3. Verify the Sentry host URL is correct @@ -222,7 +220,7 @@ To verify stdio auth behavior without an LLM provider key, use: pnpm mcp-test-client --transport stdio --list-tools ``` -This is useful for checking cached-token auth, device-code auth startup, and tool discovery over stdio. +This is useful for checking CLI-session auth and tool discovery over stdio. ### Tool Errors diff --git a/packages/mcp-test-client/src/mcp-test-client.ts b/packages/mcp-test-client/src/mcp-test-client.ts index 635940a3e..de30d700e 100644 --- a/packages/mcp-test-client/src/mcp-test-client.ts +++ b/packages/mcp-test-client/src/mcp-test-client.ts @@ -26,17 +26,24 @@ export async function connectToMCPServer( }, async (span) => { try { - // Resolve the path to the mcp-server binary - const __dirname = dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = join( - __dirname, - "../../mcp-server/dist/index.js", + // Run the workspace CLI's `sentry mcp` from source. + const cliDir = join( + dirname(fileURLToPath(import.meta.url)), + "../../cli", ); const launchConfig = buildStdioServerLaunchConfig(config); const transport = new Experimental_StdioMCPTransport({ - command: "node", - args: [mcpServerPath, ...launchConfig.args], + command: "pnpm", + args: [ + "--silent", + "--dir", + cliDir, + "run", + "cli", + "mcp", + ...launchConfig.args, + ], env: launchConfig.env, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c349635fd..9e4ba23e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,9 +471,9 @@ importers: '@sentry/core': specifier: 'catalog:' version: 11.6.0 - '@sentry/mcp-server': + '@sentry/mcp-core': specifier: workspace:* - version: link:../mcp-server + version: link:../mcp-core '@sentry/node': specifier: 'catalog:' version: 11.6.0(@babel/core@7.28.0)(rollup@4.62.4) @@ -852,51 +852,7 @@ importers: specifier: ^2.8.3 version: 2.8.3 - packages/mcp-server: - dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.32.0 - version: 1.32.0(@cfworker/json-schema@4.1.1)(zod@4.6.7) - '@sentry/core': - specifier: 'catalog:' - version: 11.6.0 - '@sentry/node': - specifier: 'catalog:' - version: 11.6.0(@babel/core@7.28.0)(rollup@4.62.4) - dotenv: - specifier: 'catalog:' - version: 16.6.1 - zod: - specifier: 'catalog:' - version: 4.6.7 - devDependencies: - '@sentry/mcp-core': - specifier: workspace:* - version: link:../mcp-core - '@sentry/mcp-server-mocks': - specifier: workspace:* - version: link:../mcp-server-mocks - '@sentry/mcp-server-tsconfig': - specifier: workspace:* - version: link:../mcp-server-tsconfig - '@sentry/toolkit-core': - specifier: workspace:* - version: link:../toolkit-core - '@types/node': - specifier: 'catalog:' - version: 22.16.0 - tsdown: - specifier: 'catalog:' - version: 0.12.9(typescript@5.9.3) - tsx: - specifier: 'catalog:' - version: 4.20.3 - typescript: - specifier: 'catalog:' - version: 5.9.3 - vitest: - specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + packages/mcp-server: {} packages/mcp-server-evals: dependencies: @@ -912,9 +868,6 @@ importers: '@sentry/mcp-core': specifier: workspace:* version: link:../mcp-core - '@sentry/mcp-server': - specifier: workspace:* - version: link:../mcp-server '@sentry/mcp-server-mocks': specifier: workspace:* version: link:../mcp-server-mocks diff --git a/scripts/check-workspace-deps.mjs b/scripts/check-workspace-deps.mjs new file mode 100644 index 000000000..37ce59af3 --- /dev/null +++ b/scripts/check-workspace-deps.mjs @@ -0,0 +1,159 @@ +/** + * Fails when workspace package manifests break the dependency direction. + * + * The CLI hosts the MCP server, so `sentry` depends on `@sentry/mcp-core`, + * which depends on `@sentry/toolkit-core`. The reverse edges would create a + * package cycle, so they are rejected here, along with any other cycle between + * workspace packages. See docs/architecture/overview.md. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const DEPENDENCY_FIELDS = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", +]; + +/** Packages that must never reach the listed packages, even transitively. */ +export const FORBIDDEN_DEPENDENCIES = { + "@sentry/toolkit-core": ["sentry", "@sentry/mcp-core"], + "@sentry/mcp-core": ["sentry", "@sentry/mcp-cloudflare"], +}; + +/** + * Build the workspace-internal dependency graph. + * + * @param {{ name: string, [field: string]: unknown }[]} manifests + * @returns {Map} + */ +export function buildGraph(manifests) { + const names = new Set(manifests.map(({ name }) => name)); + return new Map( + manifests.map((manifest) => [ + manifest.name, + [ + ...new Set( + DEPENDENCY_FIELDS.flatMap((field) => + Object.keys(manifest[field] ?? {}), + ).filter((dependency) => names.has(dependency)), + ), + ].sort(), + ]), + ); +} + +/** + * Find dependency cycles, each reported once as a closed path. + * + * @param {Map} graph + * @returns {string[][]} + */ +export function findCycles(graph) { + const cycles = []; + const seen = new Set(); + const state = new Map(); + const stack = []; + + function visit(name) { + state.set(name, "visiting"); + stack.push(name); + for (const dependency of graph.get(name) ?? []) { + if (state.get(dependency) === "visiting") { + const cycle = [...stack.slice(stack.indexOf(dependency)), dependency]; + const key = cycle + .slice(0, -1) + .toSorted((a, b) => a.localeCompare(b)) + .join("\0"); + if (!seen.has(key)) { + seen.add(key); + cycles.push(cycle); + } + } else if (!state.has(dependency)) { + visit(dependency); + } + } + stack.pop(); + state.set(name, "done"); + } + + for (const name of [...graph.keys()].sort()) { + if (!state.has(name)) { + visit(name); + } + } + return cycles; +} + +/** + * Find forbidden edges, including ones reached through other packages. + * + * @param {Map} graph + * @param {Record} [forbidden] + * @returns {string[][]} Dependency paths from the restricted package + */ +export function findForbiddenPaths(graph, forbidden = FORBIDDEN_DEPENDENCIES) { + const violations = []; + for (const [from, targets] of Object.entries(forbidden)) { + const queue = [[from]]; + const visited = new Set([from]); + while (queue.length > 0) { + const path = queue.shift(); + for (const dependency of graph.get(path.at(-1)) ?? []) { + const next = [...path, dependency]; + if (targets.includes(dependency)) { + violations.push(next); + } + if (!visited.has(dependency)) { + visited.add(dependency); + queue.push(next); + } + } + } + } + return violations; +} + +function readWorkspaceManifests(root) { + const listed = JSON.parse( + execFileSync("pnpm", ["list", "--recursive", "--depth", "-1", "--json"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }), + ); + return listed + .filter(({ path }) => resolve(path) !== root) + .map(({ path }) => + JSON.parse(readFileSync(resolve(path, "package.json"), "utf8")), + ); +} + +function main() { + const root = resolve(import.meta.dirname, ".."); + const graph = buildGraph(readWorkspaceManifests(root)); + const errors = [ + ...findCycles(graph).map( + (cycle) => `Workspace dependency cycle: ${cycle.join(" -> ")}`, + ), + ...findForbiddenPaths(graph).map( + (path) => + `Forbidden workspace dependency: ${path.join(" -> ")} (see docs/architecture/overview.md#package-dependency-direction)`, + ), + ]; + if (errors.length > 0) { + process.stderr.write(`${errors.join("\n")}\n`); + process.exitCode = 1; + return; + } + process.stdout.write( + `Workspace dependency graph OK (${graph.size} packages, no cycles).\n`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/check-workspace-deps.test.mjs b/scripts/check-workspace-deps.test.mjs new file mode 100644 index 000000000..a6ffbae5b --- /dev/null +++ b/scripts/check-workspace-deps.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildGraph, + findCycles, + findForbiddenPaths, +} from "./check-workspace-deps.mjs"; + +const layered = [ + { name: "sentry", devDependencies: { "@sentry/mcp-core": "workspace:*" } }, + { + name: "@sentry/mcp-core", + dependencies: { "@sentry/toolkit-core": "workspace:*", zod: "^4" }, + }, + { + name: "@sentry/mcp-cloudflare", + dependencies: { "@sentry/mcp-core": "workspace:*" }, + }, + { name: "@sentry/toolkit-core" }, + { name: "@sentry/mcp-server", dependencies: { sentry: "workspace:*" } }, +]; + +describe("check-workspace-deps", () => { + it("accepts the CLI -> mcp-core -> toolkit-core layering", () => { + const graph = buildGraph(layered); + assert.deepEqual(graph.get("@sentry/mcp-core"), ["@sentry/toolkit-core"]); + assert.deepEqual(findCycles(graph), []); + assert.deepEqual(findForbiddenPaths(graph), []); + }); + + it("reports a cycle once", () => { + const graph = buildGraph([ + ...layered.filter(({ name }) => name !== "@sentry/mcp-core"), + { + name: "@sentry/mcp-core", + dependencies: { "@sentry/toolkit-core": "workspace:*" }, + devDependencies: { sentry: "workspace:*" }, + }, + ]); + assert.deepEqual(findCycles(graph), [ + ["@sentry/mcp-core", "sentry", "@sentry/mcp-core"], + ]); + }); + + it("rejects mcp-core depending on the CLI, even transitively", () => { + const graph = buildGraph([ + { name: "sentry" }, + { name: "helper", dependencies: { sentry: "workspace:*" } }, + { name: "@sentry/toolkit-core" }, + { + name: "@sentry/mcp-core", + dependencies: { "@sentry/toolkit-core": "workspace:*" }, + peerDependencies: { helper: "*" }, + }, + ]); + assert.deepEqual(findForbiddenPaths(graph), [ + ["@sentry/mcp-core", "helper", "sentry"], + ]); + }); + + it("rejects toolkit-core depending on mcp-core", () => { + const graph = buildGraph([ + ...layered.filter(({ name }) => name !== "@sentry/toolkit-core"), + { + name: "@sentry/toolkit-core", + devDependencies: { "@sentry/mcp-core": "workspace:*" }, + }, + ]); + assert.deepEqual(findForbiddenPaths(graph), [ + ["@sentry/toolkit-core", "@sentry/mcp-core"], + ]); + }); +}); diff --git a/scripts/oxc-config.test.mjs b/scripts/oxc-config.test.mjs index 6d8ed0804..42415bb63 100644 --- a/scripts/oxc-config.test.mjs +++ b/scripts/oxc-config.test.mjs @@ -70,3 +70,31 @@ test("CI lint checks formatting for commits that skip the pre-commit hook", () = assert.match(scripts.lint, /pnpm run lint:format/); assert.match(workflow, /^\s*- run: pnpm run lint$/m); }); + +test("mcp-core and toolkit-core cannot import the CLI", () => { + const { overrides } = readRootJson(".oxlintrc.json"); + const { scripts } = readRootJson("package.json"); + // Oxlint replaces a rule's options per override, so the last override that + // configures no-restricted-imports for a path must carry the CLI ban. + for (const glob of [ + "packages/mcp-core/**/*.{ts,tsx,js,mjs,cjs}", + "packages/toolkit-core/src/**/*.ts", + ]) { + const override = overrides.findLast( + ({ files, rules }) => + files.includes(glob) && rules?.["no-restricted-imports"], + ); + const [level, { paths, patterns }] = + override.rules["no-restricted-imports"]; + assert.equal(level, "error", glob); + assert.ok( + paths.some(({ name }) => name === "sentry"), + glob, + ); + assert.ok( + patterns.some(({ group }) => group.includes("sentry/**")), + glob, + ); + } + assert.match(scripts.lint, /pnpm run lint:deps/); +}); diff --git a/warden.toml b/warden.toml index ce2eda93c..5fe8f8dbf 100644 --- a/warden.toml +++ b/warden.toml @@ -26,9 +26,9 @@ paths = [ "packages/mcp-core/src/skillDefinitions.json", "packages/mcp-core/src/toolDefinitions.json", "packages/mcp-core/src/tools/**", - "packages/mcp-server/src/auth/**", - "packages/mcp-server/src/index.ts", - "packages/mcp-server/src/transports/**", + "packages/mcp-core/src/transports/**", + "packages/cli/src/lib/mcp.ts", + "packages/cli/src/lib/mcp/**", "packages/mcp-cloudflare/src/server/app.ts", "packages/mcp-cloudflare/src/server/index.ts", "packages/mcp-cloudflare/src/server/authorization-server-metadata.ts",