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/.craft.yml b/.craft.yml index a51b1a97a..1475f14ba 100644 --- a/.craft.yml +++ b/.craft.yml @@ -3,9 +3,11 @@ workspaces: packages/cli: changelog: policy: auto - # The CLI release also ships the documentation workspace. + # The CLI release also ships the documentation workspace and + # @sentry/mcp-server. includePaths: - apps/cli-docs + - packages/mcp-server aiSummary: llm: model: gpt-5.6-luna-utility @@ -24,10 +26,15 @@ workspaces: Build: - "/^sentry-.*$/" - "npm-package" + - "mcp-server-npm-package" - "vercel" targets: - name: npm includeNames: '/^sentry-\d.*\.tgz$/' + # The standalone MCP server, published at the CLI's version. + - name: npm + id: "@sentry/mcp-server" + includeNames: '/^sentry-mcp-server-\d.*\.tgz$/' - name: github tagPrefix: "cli@" - name: vercel @@ -103,16 +110,3 @@ workspaces: assert_match version.to_s, shell_output("#{bin}/sentry --version").chomp end end - packages/mcp-server: - changelogPolicy: none - preReleaseCommand: >- - bash -c 'set -e; for package in packages/mcp-* packages/smoke-tests; do - (cd "$package" && npm version "$CRAFT_NEW_VERSION" --no-git-tag-version); - done' - releaseBranchPrefix: release/mcp - targets: - - name: github - includeNames: '/^sentry-mcp-server-\d.*\.tgz$/' - - name: npm - id: "@sentry/mcp-server" - includeNames: '/^sentry-mcp-server-\d.*\.tgz$/' diff --git a/.github/workflows/cli-build.yml b/.github/workflows/cli-build.yml index 33ffe585c..afd8c9ef9 100644 --- a/.github/workflows/cli-build.yml +++ b/.github/workflows/cli-build.yml @@ -289,6 +289,19 @@ jobs: with: name: npm-package path: packages/cli/*.tgz + # Every CLI release also publishes @sentry/mcp-server at the same version. + - name: Bundle mcp-server + run: pnpm --filter @sentry/mcp-server run bundle + - name: Test mcp-server bundle + run: pnpm --filter @sentry/mcp-server run test:bundle + - name: Pack mcp-server + working-directory: packages/mcp-server + run: npm pack + - name: Upload mcp-server npm artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mcp-server-npm-package + path: packages/mcp-server/*.tgz test-npm: name: Test npm Package (Node ${{ matrix.node }}) diff --git a/.github/workflows/mcp-registry.yml b/.github/workflows/mcp-registry.yml index 4cff8e1c3..59e7b1fc1 100644 --- a/.github/workflows/mcp-registry.yml +++ b/.github/workflows/mcp-registry.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: version: - description: Existing stable release (e.g. 0.40.0) + description: Existing stable @sentry/mcp-server version (e.g. 0.48.0) required: true type: string @@ -17,15 +17,17 @@ defaults: run: shell: bash +# Key on the release tag so a manual retry of `0.48.0` queues behind the +# `cli@0.48.0` release run instead of racing it to publish the same version. concurrency: - group: mcp-registry-${{ inputs.version || github.event.release.tag_name }} + group: mcp-registry-${{ github.event_name == 'release' && github.event.release.tag_name || format('cli@{0}', inputs.version) }} cancel-in-progress: false jobs: publish: if: >- (github.repository == 'getsentry/sentry-mcp' || github.repository == 'getsentry/toolkit') && - ((github.event_name == 'release' && !github.event.release.prerelease && !startsWith(github.event.release.tag_name, 'cli@')) || + ((github.event_name == 'release' && !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'cli@')) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) runs-on: ubuntu-latest timeout-minutes: 15 @@ -33,7 +35,8 @@ jobs: contents: read id-token: write env: - VERSION: ${{ inputs.version || github.event.release.tag_name }} + INPUT_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ github.event.release.tag_name }} steps: # Use the maintained manifest on main, including for older releases. - uses: actions/checkout@v4 @@ -41,13 +44,27 @@ jobs: ref: main persist-credentials: false - - name: Prepare metadata + # Each CLI release (`cli@`) publishes @sentry/mcp-server at the + # same version. Releases before that used bare `` tags. + - name: Resolve release env: GH_TOKEN: ${{ github.token }} run: | - [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] - gh release view "$VERSION" --repo getsentry/sentry-mcp --json isDraft,isPrerelease \ + if [[ -n "$RELEASE_TAG" ]]; then + tag="$RELEASE_TAG" + elif gh release view "cli@$INPUT_VERSION" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + tag="cli@$INPUT_VERSION" + else + tag="$INPUT_VERSION" + fi + version="${tag#cli@}" + [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease \ | jq -e '(.isDraft | not) and (.isPrerelease | not)' + echo "VERSION=$version" >> "$GITHUB_ENV" + + - name: Prepare metadata + run: | jq --arg version "$VERSION" '.version = $version | .packages[0].version = $version' \ server.json > "$RUNNER_TEMP/server.json" 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..1dcdb46ee 100644 --- a/.github/workflows/merge-jobs.yml +++ b/.github/workflows/merge-jobs.yml @@ -2,7 +2,7 @@ name: Post-merge tasks on: push: - branches: ["main", "release/*", "release/mcp/**"] + branches: ["main", "release/*"] workflow_dispatch: jobs: @@ -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,17 +34,6 @@ jobs: - name: Run tests run: pnpm test:ci - - name: Package mcp-server - working-directory: packages/mcp-server - run: pnpm pack --pack-destination dist - - - name: Archive artifacts - uses: actions/upload-artifact@v4 - with: - name: ${{ github.sha }} - path: | - ${{ github.workspace }}/packages/mcp-server/dist/*.tgz - - name: Publish Test Report uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857 if: ${{ !cancelled() }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54f975e10..dbea66e23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,10 +7,9 @@ on: description: Product to release required: true type: choice - default: mcp + default: cli options: - cli - - mcp bump: description: Version bump type required: true @@ -59,15 +58,13 @@ jobs: env: PRODUCT: ${{ inputs.product }} run: | + # Each product maps to a `.craft.yml` workspace. The `cli` release + # also publishes @sentry/mcp-server at the same version. case "$PRODUCT" in cli) workspace=packages/cli package_path=packages/cli ;; - mcp) - workspace=packages/mcp-server - package_path=packages/mcp-server - ;; *) echo "::error::Unsupported product: $PRODUCT" exit 1 diff --git a/.gitignore b/.gitignore index 75b67ec8b..50476eb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -78,8 +78,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..372e21a81 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 set `SENTRY_AUTH_TOKEN` (plus `SENTRY_FORCE_ENV_TOKEN=1` to override a stored login). The login is shared with the [`sentry` CLI](https://cli.sentry.dev), so an existing `sentry auth login` works too. + +To use a token, create a User Auth Token in Sentry with the necessary scopes. As of writing this is: ``` org:read @@ -59,33 +61,41 @@ event:write Launch the transport: ```shell -npx @sentry/mcp-server@latest --access-token=sentry-user-token +npx @sentry/mcp-server@latest auth login # once, to sign in +npx @sentry/mcp-server@latest # uses that login +SENTRY_AUTH_TOKEN=sentry-user-token SENTRY_FORCE_ENV_TOKEN=1 npx @sentry/mcp-server@latest ``` -Need to connect to a self-hosted deployment? Add --host (hostname -only, e.g. --host=sentry.example.com) when you run the command. -For isolated internal deployments that only expose plain HTTP, also add ---insecure-http. +`--access-token` / `SENTRY_ACCESS_TOKEN` from older releases still work but are +deprecated: they act as `SENTRY_AUTH_TOKEN` with `SENTRY_FORCE_ENV_TOKEN=1` and +print a warning on stderr. See +[docs/operations/stdio-auth.md](docs/operations/stdio-auth.md). + +Need to connect to a self-hosted deployment? Set SENTRY_HOST +(e.g. SENTRY_HOST=sentry.example.com), which also scopes +SENTRY_AUTH_TOKEN to that server. For isolated internal +deployments that only expose plain HTTP, use an http:// +SENTRY_URL instead. Seer is not part of self-hosted Sentry, so the `seer` skill is left out of the -default skill set whenever `--host` points at a non-`sentry.io` host. If your +default skill set whenever the host is not `sentry.io`. If your self-hosted deployment does run Seer, opt back in explicitly: ```shell -npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer +SENTRY_HOST=sentry.example.com npx @sentry/mcp-server@latest --skills=inspect,seer ``` You can also disable any other skill to prevent unsupported tools from being exposed: ```shell -npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com --disable-skills=project-management +SENTRY_HOST=sentry.example.com npx @sentry/mcp-server@latest --disable-skills=project-management ``` For self-hosted instances without TLS: ```shell -npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.internal:9000 --insecure-http +SENTRY_URL=http://sentry.internal:9000 npx @sentry/mcp-server@latest ``` #### Remote with an Explicit Sentry Token @@ -118,7 +128,8 @@ tools with `?skills=inspect,triage` or `?disable-skills=seer`. #### Environment Variables ```shell -SENTRY_ACCESS_TOKEN= # Required: Your Sentry auth token +SENTRY_AUTH_TOKEN= # Optional after `auth login`: Sentry auth token +SENTRY_FORCE_ENV_TOKEN= # Set to 1 to use SENTRY_AUTH_TOKEN over a stored login # 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,9 +154,10 @@ 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", + "SENTRY_AUTH_TOKEN": "your-token", + "SENTRY_FORCE_ENV_TOKEN": "1", "EMBEDDED_AGENT_PROVIDER": "openai", "OPENAI_API_KEY": "sk-..." } @@ -166,9 +178,10 @@ 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_AUTH_TOKEN": "your-token", + "SENTRY_FORCE_ENV_TOKEN": "1", "SENTRY_HOST": "sentry.example.com", "MCP_SKILLS": "inspect,seer" } 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..1ceb4c813 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 @@ -50,29 +53,26 @@ and hosted requests build independent servers without a notification channel for catalog changes. Modern `subscriptions/listen` acknowledgments therefore omit `toolsListChanged`, following the [MCP subscription contract](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions). -**Note:** This package is **not published to npm**. It's a workspace-only package. - -### packages/mcp-server +**Note:** mcp-core is private and never published on its own. The CLI +bundles it, and so do `@sentry/mcp-server` (through the CLI's `sentry mcp` +server) and the Cloudflare worker. -Published stdio transport package (as `@sentry/mcp-server`). - -**Structure:** +### packages/cli (`sentry mcp`) -``` -src/ -├── cli/ # CLI argument parsing -├── transports/ # stdio transport -└── index.ts # Main entry point -``` +The local stdio MCP server is the `sentry mcp` command in the Sentry CLI. -**Key responsibilities:** +- `src/lib/mcp.ts` resolves credentials from the CLI session or env token + (mapping the deprecated `--access-token` / `SENTRY_ACCESS_TOKEN` onto + `SENTRY_AUTH_TOKEN` with `SENTRY_FORCE_ENV_TOKEN=1`) 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. -- 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 +114,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 @@ -158,14 +184,15 @@ The MCP server supports multiple transport mechanisms: ### 3. Authentication Strategy -The MCP server uses Sentry access tokens for authentication: +The stdio server uses the Sentry CLI's credentials: the stored login from +`auth login`, or `SENTRY_AUTH_TOKEN` (see `docs/operations/stdio-auth.md`): ```bash -# Via command line -pnpm start:stdio --access-token= --host= +# With the stored login +pnpm start:stdio -# Via environment variable -SENTRY_ACCESS_TOKEN= pnpm start:stdio +# With a token, over any stored login, on a self-hosted server +SENTRY_AUTH_TOKEN= SENTRY_FORCE_ENV_TOKEN=1 SENTRY_HOST= pnpm start:stdio ``` For web applications using MCP (like the Cloudflare example), they handle their own authentication and pass tokens to the MCP server. 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..54cd60e34 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 ``` #### Method 3: MCP Configuration File @@ -95,9 +95,10 @@ 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", + "SENTRY_AUTH_TOKEN": "your-token", + "SENTRY_FORCE_ENV_TOKEN": "1", "EMBEDDED_AGENT_PROVIDER": "openai", "OPENAI_API_KEY": "sk-..." } @@ -137,9 +138,10 @@ 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", + "SENTRY_AUTH_TOKEN": "your-token", + "SENTRY_FORCE_ENV_TOKEN": "1", "EMBEDDED_AGENT_PROVIDER": "openai", "OPENAI_API_KEY": "sk-..." } @@ -194,7 +196,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 +208,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 +235,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 2>&1 | grep "Using" ``` Expected output: @@ -290,7 +292,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 ``` 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..547e16633 100644 --- a/docs/operations/monitoring.md +++ b/docs/operations/monitoring.md @@ -8,14 +8,17 @@ Different Sentry SDKs for different environments: - **Core server**: `@sentry/core` (platform-agnostic) - **Cloudflare Workers**: `@sentry/cloudflare` -- **Node.js stdio**: `@sentry/node` +- **Node.js stdio**: `@sentry/node`. Without `--sentry-dsn`/`SENTRY_DSN`, the + stdio server reports to the Sentry CLI's project and honors the CLI's + opt-outs (`SENTRY_CLI_NO_TELEMETRY=1`, `DO_NOT_TRACK=1`, or + `sentry cli defaults telemetry off`). - **React client**: `@sentry/react` ## Core Server Instrumentation ### 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. @@ -177,6 +180,7 @@ These are Sentry MCP application attributes that are not part of the MCP semanti - `app.constraint.organization_slug` - Session organization constraint - `app.constraint.project_slug` - Session project constraint - `app.server.mode.experimental` - Whether experimental tools are enabled for this MCP request or stdio session +- `app.server.distribution` - stdio only: `@sentry/mcp-server` for the npm package, `sentry mcp` for the CLI command - `app.consent.skill..granted` - Per-skill boolean attributes for skills granted to the MCP request. Skill ids are normalized with `-` replaced by `_` - `app.upstream.host` - Upstream Sentry host configured for the server - `app.url.full` - MCP URL configured for stdio @@ -365,7 +369,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..cc8710bae 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -1,141 +1,120 @@ # 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. +How the stdio MCP server (`@sentry/mcp-server`) authenticates with Sentry. -## Authentication Methods +Sign in once with `npx -y @sentry/mcp-server auth login`. The package runs +the Sentry CLI's own `auth` commands and stores the session in the CLI's +credential store, so the package and the CLI (which runs the same server as +`sentry mcp`) share one login. -The stdio transport supports two authentication methods, in order of precedence: +## Credential Sources -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`. +The server uses the CLI's credential resolution, so the rules match every +other `sentry` command: -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. +1. **Stored login** — the account from `auth login`. It wins by default + because it can be refreshed. +2. **`SENTRY_AUTH_TOKEN`** (or the legacy `SENTRY_TOKEN`) — used when there is + no stored login. Set `SENTRY_FORCE_ENV_TOKEN=1` to use it even when a stored + login exists. -## Device Code Flow +Both 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. An env token is never +refreshed; the forced refresh re-reads it. -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 +To run the server with a token instead of a login, configure the MCP client +with environment variables: ```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" + "command": "npx", + "args": ["-y", "@sentry/mcp-server@latest"], + "env": { + "SENTRY_AUTH_TOKEN": "sntryu_...", + "SENTRY_FORCE_ENV_TOKEN": "1" } } ``` -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 +Org auth tokens (`sntrys_`) record the Sentry URL they were issued for; the +server refuses to send one to a different host (`HostScopeError`). -### Override +### Deprecated: `--access-token` and `SENTRY_ACCESS_TOKEN` -Set `SENTRY_MCP_AUTH_CACHE` to override the cache file path (primarily for testing). +Older `@sentry/mcp-server` releases took a static token from `--access-token` +or `SENTRY_ACCESS_TOKEN`. Both still work, as an alias: the server runs as if +`SENTRY_AUTH_TOKEN=` and `SENTRY_FORCE_ENV_TOKEN=1` were set, so the +token wins over a stored login and takes the same env-token path. It also +replaces any `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` already in the environment, +because the flag is the more explicit choice. `--access-token` wins over +`SENTRY_ACCESS_TOKEN`. Unlike a plain env token, the alias is sent to the +`--host`/`--url` target, as the old static token was (an org token's own URL +claim still has to match it). -## Non-Interactive Contexts +Either one prints a single deprecation warning to stderr (stdout carries the +MCP protocol), without the token: -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 +``` +Deprecation warning: --access-token is deprecated; set SENTRY_AUTH_TOKEN (and SENTRY_FORCE_ENV_TOKEN=1 to override a stored login) instead. ``` -These commands support `--host` and `--url` flags with the same precedence as the main server: `--url` beats `--host`, CLI beats env vars. +The flag value is redacted from the CLI's telemetry like `--token`. + +### Interactive Login and Self-Hosted Servers + +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 for the +program that is running, such as `npx -y @sentry/mcp-server auth login` +(`packages/cli/src/lib/app-identity.ts`). + +For self-hosted Sentry, set `SENTRY_HOST`/`SENTRY_URL`. Those also pick the +stored account for that server (see [Accounts](#accounts)) and scope +`SENTRY_AUTH_TOKEN` to it; `--host`/`--url` alone set the target but do not +scope an env token, so the server refuses to start (`HostScopeError`). A plain +HTTP instance takes an `http://` `SENTRY_URL`, or `--host` with +`--insecure-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 to that host. An org +auth token (`sntrys_`) passed through the deprecated alias is the exception: +it names the server it was issued for, so it is served when that server +matches the target and refused when it does not. + +## 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 +(`npx -y @sentry/mcp-server 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 +`SENTRY_AUTH_TOKEN` and `SENTRY_FORCE_ENV_TOKEN=1`. + +## 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 set +`SENTRY_AUTH_TOKEN` (with `SENTRY_FORCE_ENV_TOKEN=1` to override a stored +login). `--access-token` / `SENTRY_ACCESS_TOKEN` keep working but are +deprecated (see above). `auth status`, `auth list`, +`auth switch`, and `auth logout` work in the package as in the CLI. ## 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, `--access-token` alias, refresh-on-401 | +| `packages/cli/src/commands/mcp.ts` | `mcp` command flags and help | +| `packages/cli/src/lib/mcp/server.ts` | Agent providers, 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..11e35e902 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -1,105 +1,98 @@ # 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`. Its source lives in +`packages/cli` so it shares the CLI's auth, which also exposes it as +`sentry mcp`. Every CLI release publishes the package at the CLI's version, so +`@sentry/mcp-server@0.48.0` runs the same server as `sentry mcp` in CLI 0.48.0. + +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, with + `SENTRY_FORCE_ENV_TOKEN=1` to override a stored login +- the deprecated `--access-token` / `SENTRY_ACCESS_TOKEN`, as an alias for + `SENTRY_AUTH_TOKEN` plus `SENTRY_FORCE_ENV_TOKEN=1`. It prints a deprecation + warning on stderr (see `docs/operations/stdio-auth.md`). + +## Bundle + +The package is a single file with no runtime dependencies. +`packages/mcp-server/script/bundle.ts` bundles the CLI's +`packages/cli/src/mcp-server.ts` into `dist/sentry-mcp.cjs`: + +- The entry runs the CLI's own runner (`packages/cli/src/cli.ts`) with a + smaller application, `packages/cli/src/mcp-server-app.ts`: `mcp` (the + default command), `auth`, and `help`. So `sentry-mcp` starts the server, + `sentry-mcp auth login|status|list|switch|logout` are the CLI's `auth` + commands, and flags and `--help` come from the same Stricli definitions as + `sentry mcp`. Hints name the package: `sentry-mcp auth login`, or + `npx -y @sentry/mcp-server auth login` when npx started it + (`packages/cli/src/lib/app-identity.ts`). +- The runner starts `mcp` without the CLI's telemetry, error recovery, and + update check: the server owns stdout and reports its own telemetry. The + command resolves when the client closes stdin, and only then does the + runner release the network. The server stack (`@sentry/mcp-core`'s server + and tools, the agent providers, MCP telemetry) loads only when the server + starts, and a bare `--version` (or a lone `-v`, as in the old package) + returns before the application loads. +- stdio runs on MCP SDK v2 (`@modelcontextprotocol/server`), like the + Cloudflare worker; mcp-core no longer ships SDK v1's server. `zod/v3` is + stubbed in this bundle and the CLI's + (`packages/cli/script/lib/zod-v3-stub-plugin.ts`): only Zod 3 code paths in + `@ai-sdk/provider-utils` and `@mastra/schema-compat` use it, and everything + bundled builds Zod 4 schemas. Zod keeps only its English locale + (`packages/cli/script/lib/zod-locales-plugin.ts`). +- CLI-only code stays out: the other commands, agent skills, completions, + syntax highlighting, the Ink UI, and debug-file processing. Help and `auth` + output render code blocks without colors, and account pickers take the + account as an argument instead of the Ink picker. 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. Packages that only those code paths import resolve to empty stub + modules, so the output never `require`s a package the tarball does not + ship. + +`pnpm --filter @sentry/mcp-server run bundle` builds it and `run test:bundle` +smoke-tests the result. + +## Release + +The `packages/cli` Craft workspace releases both packages: run the **Release** +workflow with `product: cli`. + +- `packages/cli/script/bump-version.ts` sets `packages/mcp-server/package.json` + to the CLI's version, both for the release and for the next `-dev` version. + The bundle script fails if the two versions differ. +- `.github/workflows/cli-build.yml` bundles, tests, and packs the package next + to the CLI's npm package and uploads it as `mcp-server-npm-package`. +- Craft publishes it to npm with the CLI and attaches the tarball to the + `cli@` GitHub release. + +The other `mcp-*` packages are private and keep their own versions. ## 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 +`cli@` 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 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 (CLI release tags, or the bare tags +used up to 0.42.0): ```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.48.0 ``` An identical active listing is safe to rerun. Different metadata for an existing @@ -108,127 +101,49 @@ 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 - -```json -{ - "mcpServers": { - "sentry": { - "command": "npx", - "args": ["-y", "@sentry/mcp-server"], - "env": { - "SENTRY_ACCESS_TOKEN": "sntrys_...", - "SENTRY_HOST": "sentry.io" - } - } - } -} -``` - -Config location: - -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -### Cursor IDE - -Add to `.cursor/mcp.json`: +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"] } } } ``` -## 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`) +Sign in once with `npx @sentry/mcp-server@latest auth login`, or add +`"env": { "SENTRY_AUTH_TOKEN": "sntryu_...", "SENTRY_FORCE_ENV_TOKEN": "1" }`. +Pin a version with `@sentry/mcp-server@0.48.0`. -## Version Pinning +## Testing Before a Release -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: - -```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 stdio server -SENTRY_ACCESS_TOKEN=... @sentry/mcp-server -``` - -### Beta Releases - -For testing with users before stable release: +Test the package itself, not just the CLI source it is built from: ```bash -npm publish --tag beta -``` +# Build and smoke-test the bundle +pnpm --filter @sentry/mcp-server run bundle +pnpm --filter @sentry/mcp-server run test:bundle -Users install with: +# Exercise the built bundle with the test client +pnpm -w run cli --transport stdio --stdio-bundle "who am I?" -```json -{ - "args": ["-y", "@sentry/mcp-server@beta"] -} +# Install the packed tarball the way users do +(cd packages/mcp-server && pnpm pack) +npx -y --package=./packages/mcp-server/sentry-mcp-server-*.tgz sentry-mcp --help ``` -## 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 +While iterating, `pnpm -w run cli --transport stdio` (without +`--stdio-bundle`) runs `sentry mcp` from CLI source, which needs no build. ## 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: `packages/cli/src/commands/mcp.ts` (flags and help) +- Credentials and startup: `packages/cli/src/lib/mcp.ts` +- Host (env, config, 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` application: `packages/cli/src/mcp-server-app.ts` +- `@sentry/mcp-server` bundle: `packages/mcp-server/script/bundle.ts` diff --git a/docs/specs/embedded-agent-openai-routing.md b/docs/specs/embedded-agent-openai-routing.md index e247ac21d..a0dc36072 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/commands/mcp.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..29a7636c8 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,88 +41,50 @@ 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 +## Running Stdio Locally -### 1. Initial Setup +### 1. Setup ```bash -# Clone and install dependencies -cd sentry-mcp pnpm install - -# Set up environment (optional for stdio) -make setup-env +pnpm -w run build # builds mcp-core, which `sentry mcp` imports ``` -### 2. Build the Package +### 2. Run From Source ```bash -# Build all packages (includes mcp-server) -pnpm -w run build - -# Or build just mcp-server -cd packages/mcp-server -pnpm build +# 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 +SENTRY_AUTH_TOKEN=YOUR_TOKEN SENTRY_FORCE_ENV_TOKEN=1 pnpm --silent --dir packages/cli run cli mcp ``` -The build process: +`--silent` keeps pnpm's banner off stdout, which carries JSON-RPC frames. -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 +### 3. Run the Built Package -**Output location:** `packages/mcp-server/dist/` - -### 3. Verify Build +Source runs skip the bundling step, so check the bundle that ships as +`@sentry/mcp-server` before a release: ```bash -# Check the built executable exists -ls -la packages/mcp-server/dist/index.js +pnpm --filter @sentry/mcp-server run bundle +node packages/mcp-server/dist/sentry-mcp.cjs mcp --help +SENTRY_AUTH_TOKEN=YOUR_TOKEN SENTRY_FORCE_ENV_TOKEN=1 node packages/mcp-server/dist/sentry-mcp.cjs -# Test the CLI help -node packages/mcp-server/dist/index.js --help +# Or drive it with the test client +pnpm -w run cli --transport stdio --stdio-bundle --list-tools ``` -## Running Stdio Locally - -### Option 1: Using pnpm start (Development) - -Best for active development with TypeScript sources: - -```bash -cd packages/mcp-server -pnpm start --access-token=YOUR_TOKEN -``` - -This uses `tsx` to run TypeScript directly without building. - -### Option 2: Using the Built Package (Production-like) - -Test the actual build output: +### 4. Run the Published Package ```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 +npx @sentry/mcp-server@latest +SENTRY_AUTH_TOKEN=YOUR_TOKEN SENTRY_FORCE_ENV_TOKEN=1 npx @sentry/mcp-server@latest ``` -### Option 3: Using npx (End-user Experience) - -Test the published package experience: - -```bash -# Latest from npm -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 @@ -130,8 +93,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,23 +113,14 @@ 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", - "--access-token=YOUR_TOKEN" - ] + "/absolute/path/to/toolkit/packages/cli", + "run", + "cli", + "mcp" + ], + "env": { "SENTRY_AUTH_TOKEN": "YOUR_TOKEN", "SENTRY_FORCE_ENV_TOKEN": "1" } } ``` @@ -175,11 +129,12 @@ This opens the MCP Inspector at `http://localhost:6274` ```json { "command": "npx", - "args": [ - "@sentry/mcp-server@latest", - "--access-token=YOUR_TOKEN", - "--host=sentry.example.com" - ] + "args": ["@sentry/mcp-server@latest"], + "env": { + "SENTRY_AUTH_TOKEN": "YOUR_TOKEN", + "SENTRY_FORCE_ENV_TOKEN": "1", + "SENTRY_HOST": "sentry.example.com" + } } ``` @@ -188,12 +143,12 @@ This opens the MCP Inspector at `http://localhost:6274` ```json { "command": "npx", - "args": [ - "@sentry/mcp-server@latest", - "--access-token=YOUR_TOKEN", - "--host=sentry.internal:9000", - "--insecure-http" - ] + "args": ["@sentry/mcp-server@latest"], + "env": { + "SENTRY_AUTH_TOKEN": "YOUR_TOKEN", + "SENTRY_FORCE_ENV_TOKEN": "1", + "SENTRY_URL": "http://sentry.internal:9000" + } } ``` @@ -334,18 +289,20 @@ Create or edit `.cursor/config.json`: "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server@latest", "--access-token=YOUR_TOKEN"] + "args": ["@sentry/mcp-server@latest"] } } } ``` -**2. Use Environment Variables (Recommended):** +This uses your CLI login (`npx @sentry/mcp-server@latest auth login`). + +**2. Or Use a Token:** Create `.cursor/.env`: ```bash -SENTRY_ACCESS_TOKEN=your-token-here +SENTRY_AUTH_TOKEN=your-token-here ``` Update config: @@ -357,7 +314,8 @@ Update config: "command": "npx", "args": ["@sentry/mcp-server@latest"], "env": { - "SENTRY_ACCESS_TOKEN": "${SENTRY_ACCESS_TOKEN}" + "SENTRY_AUTH_TOKEN": "${SENTRY_AUTH_TOKEN}", + "SENTRY_FORCE_ENV_TOKEN": "1" } } } @@ -387,7 +345,11 @@ Add to `.vscode/settings.json`: "mcp.servers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server@latest", "--access-token=YOUR_TOKEN"] + "args": ["@sentry/mcp-server@latest"], + "env": { + "SENTRY_AUTH_TOKEN": "YOUR_TOKEN", + "SENTRY_FORCE_ENV_TOKEN": "1" + } } } } @@ -400,42 +362,50 @@ 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: use SENTRY_AUTH_TOKEN (below) or a CLI login +--access-token=TOKEN # Deprecated alias for SENTRY_AUTH_TOKEN + SENTRY_FORCE_ENV_TOKEN=1 # 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 +--version, -v # Show version (-v alone; elsewhere it is --verbose) ``` ### Environment Variables ```bash -# Authentication -SENTRY_ACCESS_TOKEN=your-token +# Authentication (optional with a CLI login) +SENTRY_AUTH_TOKEN=your-token # Used when there is no CLI login +SENTRY_FORCE_ENV_TOKEN=1 # Use SENTRY_AUTH_TOKEN over a CLI login +SENTRY_ACCESS_TOKEN=your-token # Deprecated, same as --access-token -# Host (self-hosted only) +# Host (self-hosted only; also scopes SENTRY_AUTH_TOKEN to it) 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,93 +425,51 @@ 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 ``` ### 2. Test Against Self-Hosted Sentry ```bash -# Development server -pnpm start \ - --access-token=TOKEN \ - --host=sentry.local.dev - -# Built package -node dist/index.js \ - --access-token=TOKEN \ - --host=sentry.local.dev +SENTRY_AUTH_TOKEN=TOKEN SENTRY_FORCE_ENV_TOKEN=1 SENTRY_HOST=sentry.local.dev \ + pnpm --silent --dir packages/cli run cli mcp ``` ### 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:** +**Cause:** No CLI login and no `SENTRY_AUTH_TOKEN`. -```bash -# Use local build instead -cd packages/mcp-server -pnpm build -node dist/index.js --access-token=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 `npx @sentry/mcp-server@latest auth login` (from source: +`pnpm --silent --dir packages/cli run cli auth login`), or set +`SENTRY_AUTH_TOKEN` in the client's `env`. 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 +479,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 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 ``` ## 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 6cd79d627..86877b8a6 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 (built by `packages/mcp-server/script/bundle.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 84d5dfad8..07f3591b1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,10 +59,10 @@ "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", + "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", @@ -104,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/plugins/sentry-cli/skills/sentry-cli/references/mcp.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md index ceaa7d886..c0d1536f1 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md @@ -15,4 +15,42 @@ Start a local Sentry MCP server Start a local Sentry MCP server +**Flags:** +- `--access-token - Deprecated: set SENTRY_AUTH_TOKEN (and SENTRY_FORCE_ENV_TOKEN=1) instead` +- `--host - Sentry host for self-hosted instances` +- `--url - Sentry URL (https only; use --host for http)` +- `--insecure-http - Use http:// for a self-hosted --host value` +- `--organization-slug - Limit every call to this organization` +- `--project-slug - Limit calls to this project` +- `--skills - Skills to grant, comma-separated (default: active skills; seer is left out on self-hosted)` +- `--all-skills - Grant every active skill, including seer on self-hosted` +- `--disable-skills - Skills to remove, comma-separated` +- `--experimental - Enable forward-looking tool variants and experimental features` +- `--agent-provider - LLM provider for AI-powered search: openai, azure-openai, anthropic, or openrouter` +- `--openai-base-url - OpenAI API base URL` +- `--openai-model - OpenAI model (default: gpt-5)` +- `--anthropic-base-url - Anthropic API base URL` +- `--anthropic-model - Anthropic model (default: claude-sonnet-4-5)` +- `--mcp-url - MCP server URL reported in telemetry` +- `--sentry-dsn - DSN for the server's own telemetry` + +**Examples:** + +```bash +# Start the server with the current CLI login +sentry mcp + +# Grant every skill, including seer on self-hosted +sentry mcp --all-skills + +# Grant only some skills +sentry mcp --skills inspect,triage + +# Use a self-hosted instance over plain HTTP +sentry mcp --host sentry.internal:9000 --insecure-http + +# Pick the LLM provider for AI-powered search +sentry mcp --agent-provider anthropic +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index bb4afa647..4d2ba08e1 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -38,6 +38,7 @@ import { build as esbuild } from "esbuild"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; import { zodEnglishLocalesPlugin } from "./lib/zod-locales-plugin.js"; +import { zodV3StubPlugin } from "./lib/zod-v3-stub-plugin.js"; import { textImportPlugin } from "./text-import-plugin.js"; const gzipAsync = promisify(gzip); @@ -162,6 +163,7 @@ async function bundleJs(): Promise { plugins: [ textImportPlugin, zodEnglishLocalesPlugin, + zodV3StubPlugin, // Transform _require() → require() so esbuild resolves lazy relative // requires at bundle time. In tsx dev mode, _require is a file-local // createRequire(import.meta.url) that resolves relative to the file. diff --git a/packages/cli/script/bump-version.ts b/packages/cli/script/bump-version.ts index 9a2fe1ff8..36b51c6b7 100644 --- a/packages/cli/script/bump-version.ts +++ b/packages/cli/script/bump-version.ts @@ -3,6 +3,7 @@ * Version Bump Script * * Handles version bumping for releases, keeping package.json and plugin.json in sync. + * `@sentry/mcp-server` ships with every CLI release, so it takes the same version. * Extracted from .craft.yml inline scripts for maintainability. * * Usage: @@ -10,11 +11,12 @@ * node --experimental-strip-types script/bump-version.ts --post # Post-release: bump to next dev version * * Pre-release (--pre): - * - Sets package.json version to CRAFT_NEW_VERSION + * - Sets the CLI and @sentry/mcp-server package.json versions to CRAFT_NEW_VERSION * - Updates plugin.json version (strips prerelease suffix) * * Post-release (--post): - * - Bumps package.json to next preminor with -dev prerelease + * - Bumps package.json to next preminor with -dev prerelease, and + * @sentry/mcp-server to match * - Updates plugin.json version * - Commits and pushes if there are changes */ @@ -24,6 +26,7 @@ import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const PLUGIN_PATH = "plugins/sentry-cli/.claude-plugin/plugin.json"; +const MCP_SERVER_PATH = "../mcp-server"; const SKILL_PATH = "plugins/sentry-cli/skills/sentry-cli"; const SKILL_VERSION_LINE = /^version: [^\r\n]+$/gm; @@ -94,8 +97,17 @@ function syncSkillVersions(version: string): void { /** * Execute a shell command with output inherited to stdout/stderr */ -function exec(command: string): void { - execSync(command, { stdio: "inherit" }); +function exec(command: string, cwd?: string): void { + execSync(command, { stdio: "inherit", cwd }); +} + +/** Give `@sentry/mcp-server` the CLI's version. */ +function setMcpServerVersion(version: string): void { + exec( + `npm --no-git-tag-version --allow-same-version version ${version}`, + MCP_SERVER_PATH, + ); + console.log(`Updated @sentry/mcp-server to ${version}`); } /** @@ -112,6 +124,7 @@ function preRelease(): void { // Update package.json via npm (handles formatting consistently) exec(`npm --no-git-tag-version version ${version}`); + setMcpServerVersion(version); // Update plugin.json (strip prerelease suffix for cleaner version) const plugin = readPluginJson(); @@ -135,6 +148,7 @@ function postRelease(): void { version: string; }; console.log(`package.json bumped to ${pkg.version}`); + setMcpServerVersion(pkg.version); // Update plugin.json const plugin = readPluginJson(); diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index 0d0d06194..61c49f008 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -7,7 +7,9 @@ import pkg from "../package.json"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; import { zodEnglishLocalesPlugin } from "./lib/zod-locales-plugin.js"; +import { zodV3StubPlugin } from "./lib/zod-v3-stub-plugin.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; @@ -159,40 +161,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. - * `formatters/highlight` loads cli-highlight only on the first code block. - */ -const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/](?:highlight|json)|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 @@ -256,6 +230,7 @@ const zlibNamespaceShimPlugin: Plugin = { const plugins: Plugin[] = [ sentrySourcemapPlugin, zodEnglishLocalesPlugin, + zodV3StubPlugin, textImportPlugin, requireAliasPlugin, ]; 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..f444b5884 --- /dev/null +++ b/packages/cli/script/lib/require-alias-plugin.ts @@ -0,0 +1,31 @@ +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. + * `formatters/highlight` loads cli-highlight only on the first code block. + */ +const REQUIRE_ALIAS_FILTER = + /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/](?:highlight|json)|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/script/lib/zod-v3-stub-plugin.ts b/packages/cli/script/lib/zod-v3-stub-plugin.ts new file mode 100644 index 000000000..91fe579d6 --- /dev/null +++ b/packages/cli/script/lib/zod-v3-stub-plugin.ts @@ -0,0 +1,51 @@ +/** + * esbuild plugin that replaces `zod/v3` with a small stub. + * + * Everything we bundle builds schemas with Zod 4, so Zod 3 code never runs. + * Two dependencies still import `zod/v3`, and only for Zod 3 inputs: + * + * - `@ai-sdk/provider-utils`: `zodSchema()` sends Zod 4 schemas (`_zod`) to + * `zod4Schema()`. Only `zod3Schema()` and its bundled zod-to-json-schema + * read `ZodFirstPartyTypeKind`. + * - `@mastra/schema-compat` (via `@mastra/client-js`, used by `sentry init`): + * its Zod 3 to JSON Schema converter reads `ZodFirstPartyTypeKind`, and + * `isZodV3()` ends with `schema instanceof z3.ZodType`. `sentry init` only + * uses the workflow API, which converts no schemas. + * + * The stub keeps `ZodType` a class, so that `instanceof` check is `false` + * instead of a `TypeError`. `ZodFirstPartyTypeKind` throws on access, so a + * Zod 3 schema that does reach a converter fails with a clear error rather + * than producing a wrong JSON Schema. + * + * Shared by the CLI bundles (`script/bundle.ts`, `script/build.ts`) and the + * `@sentry/mcp-server` bundle. + */ + +import type { Plugin } from "esbuild"; + +const ZOD_V3_NS = "zod-v3-stub"; + +const ZOD_V3_STUB = ` +export class ZodType {} +export const ZodFirstPartyTypeKind = new Proxy({}, { + get(_target, key) { + if (typeof key === "symbol" || key === "then") return undefined; + throw new Error("Zod 3 schemas are not supported in this build (zod/v3 is not bundled)."); + }, +}); +export default { ZodType, ZodFirstPartyTypeKind }; +`; + +export const zodV3StubPlugin: Plugin = { + name: "zod-v3-stub", + setup(build) { + build.onResolve({ filter: /^zod\/v3$/ }, () => ({ + path: "zod/v3", + namespace: ZOD_V3_NS, + })); + build.onLoad({ filter: /.*/, namespace: ZOD_V3_NS }, () => ({ + contents: ZOD_V3_STUB, + loader: "js", + })); + }, +}; diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 805819c4f..bfb04b291 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -1,11 +1,3 @@ -import { - type ApplicationText, - ArgumentScannerError, - buildApplication, - text_en, - UnexpectedPositionalError, - UnsatisfiedPositionalError, -} from "@stricli/core"; import { conversationRoute } from "./commands/agent-conversation/index.js"; import { listCommand as conversationListCommand } from "./commands/agent-conversation/list.js"; import { alertRoute } from "./commands/alert/index.js"; @@ -71,29 +63,10 @@ import { listCommand as traceListCommand } from "./commands/trace/list.js"; import { trialRoute } from "./commands/trial/index.js"; import { listCommand as trialListCommand } from "./commands/trial/list.js"; import { wasmSplitCommand } from "./commands/wasm-split.js"; -import { - getCommandSuggestion, - getSynonymSuggestionFromArgv, -} from "./lib/command-suggestions.js"; -import { CLI_VERSION } from "./lib/constants.js"; -import { redactCredentialText } from "./lib/credential-redaction.js"; -import { reportArgumentError, reportCliError } from "./lib/error-reporting.js"; -import { - ApiError, - AuthError, - CliError, - formatError, - getExitCode, - OutputError, - WizardError, -} from "./lib/errors.js"; -import { error as errorColor, warning } from "./lib/formatters/colors.js"; -import { stripAnsi } from "./lib/formatters/plain-detect.js"; -import { buildTopLevelFlags } from "./lib/global-flags.js"; -import { renderJsonHelp } from "./lib/help.js"; -import { isRouteMap, type RouteMap } from "./lib/introspect.js"; +import { buildCliApplication } from "./lib/application.js"; +import { setGraphicsBanner } from "./lib/help.js"; import { buildRouteMap } from "./lib/route-map.js"; -import { setRoutes } from "./lib/route-registry.js"; +import { graphicsBanner, graphicsBannerExtraRows } from "./lib/sixel.js"; /** * Plural alias → singular route name mapping. @@ -228,257 +201,14 @@ export const routes = buildRouteMap({ }, }); -// Library modules (help, completions, ...) read the command tree from the -// registry instead of importing this module, which would pull in every command. -setRoutes(routes); - -/** - * Route group names that have `defaultCommand` set. - * - * Derived from the route map at module load time — no manual list to maintain. - * Used to detect the no-args case (`sentry issue` with no subcommand) - * so we can show a usage hint instead of a confusing parse error. - */ -const routesWithDefaultCommand: ReadonlySet = new Set( - routes - .getAllEntries() - .filter( - (e) => - isRouteMap(e.target as unknown) && - (e.target as unknown as RouteMap).getDefaultCommand?.(), - ) - .map((e) => e.name.original), -); - -/** - * Detect when the user typed a bare route group with no subcommand (e.g., `sentry issue`). - * - * With `defaultCommand: "view"` on route groups, Stricli routes to the view - * command which then fails with UnsatisfiedPositionalError because no issue ID - * was provided. Returns a usage hint string, or undefined if this isn't the - * bare-route-group case. - */ -function detectBareRouteGroup(ansiColor: boolean): string | undefined { - const args = process.argv.slice(2); - const nonFlags = args.filter((t) => !t.startsWith("-")); - if ( - nonFlags.length <= 1 && - nonFlags[0] && - routesWithDefaultCommand.has(nonFlags[0]) - ) { - const route = nonFlags[0]; - const msg = `Usage: sentry ${route} [args]\nRun "sentry ${route} --help" to see available commands`; - return ansiColor ? warning(msg) : msg; - } - return; -} - -/** - * Detect when a plural alias received extra positional args and suggest the - * singular form. E.g., `sentry projects view cli` → `sentry project view cli`. - */ -function detectPluralAliasMisuse(ansiColor: boolean): string | undefined { - const args = process.argv.slice(2); - const firstArg = args[0]; - if (firstArg && firstArg in PLURAL_TO_SINGULAR) { - const singular = PLURAL_TO_SINGULAR[firstArg]; - const rest = args.slice(1).join(" "); - return ansiColor - ? warning(`\nDid you mean: sentry ${singular} ${rest}\n`) - : `\nDid you mean: sentry ${singular} ${rest}\n`; - } - return; -} - -/** - * Format a CliError with a synonym suggestion when the user typed a known - * synonym that was consumed as a positional arg by `defaultCommand: "view"`. - * - * Returns the formatted error string if a synonym match is found, - * undefined otherwise. Skips Sentry capture for these known user mistakes. - */ -function formatSynonymError( - exc: unknown, - ansiColor: boolean, -): string | undefined { - if (!(exc instanceof CliError)) { - return; - } - const synonymHint = getSynonymSuggestionFromArgv(); - if (!synonymHint) { - return; - } - const prefix = ansiColor ? errorColor("Error:") : "Error:"; - const tip = ansiColor - ? warning(`Tip: ${synonymHint}`) - : `Tip: ${synonymHint}`; - return `${prefix} ${exc.format()}\n${tip}`; -} - -function escapesToOuterMiddleware(exc: unknown): boolean { - if (exc instanceof OutputError) { - return true; - } - if (exc instanceof AuthError) { - return exc.reason === "not_authenticated" || exc.reason === "expired"; - } - return exc instanceof ApiError && (exc.status === 401 || exc.status === 403); -} - -/** Message for an argument that failed to parse, with our hints. */ -function formatArgumentError(exc: unknown, ansiColor: boolean): string { - // Case A: bare route group with no subcommand (e.g., `sentry issue`) - if (exc instanceof UnsatisfiedPositionalError) { - const bareHint = detectBareRouteGroup(ansiColor); - if (bareHint) { - return bareHint; - } - } - - // Case B + plural alias: extra args that Stricli can't consume - if (exc instanceof UnexpectedPositionalError) { - const pluralHint = detectPluralAliasMisuse(ansiColor); - if (pluralHint) { - return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${pluralHint}`; - } - - // With defaultCommand: "view", unknown tokens like "metrics" fill the - // positional slot, then extra args (e.g., CLI-AB) trigger this error. - // Check if the first non-route token is a known synonym. - const synonymHint = getSynonymSuggestionFromArgv(); - if (synonymHint) { - const tip = ansiColor - ? warning(`\nTip: ${synonymHint}`) - : `\nTip: ${synonymHint}`; - return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${tip}`; - } - } - - return text_en.exceptionWhileParsingArguments(exc, ansiColor); -} - -/** - * Custom error formatting for CLI errors. - * - * - AuthError (not_authenticated): Re-thrown to allow auto-login flow in bin.ts - * - Other CliError subclasses: Show clean user-friendly message without stack trace - * - Other errors: Show stack trace for debugging unexpected issues - */ -const customText: ApplicationText = { - ...text_en, - exceptionWhileParsingArguments: ( - exc: unknown, - ansiColor: boolean, - ): string => { - const message = formatArgumentError(exc, ansiColor); - // Exit 252 never reaches reportCliError; record it for UX research - reportArgumentError(exc, stripAnsi(message)); - return message; - }, - noCommandRegisteredForInput: ({ input, corrections, ansiColor }): string => { - // Default error message from Stricli (e.g., "No command registered for `info`") - const base = text_en.noCommandRegisteredForInput({ - input, - corrections, - ansiColor, - }); - - // Check for known synonym suggestions on routes without defaultCommand - // (e.g., `sentry cli info` → suggest `sentry auth status`). - // Routes WITH defaultCommand won't reach here — their unknown tokens - // are consumed as positional args and handled by Cases A/B/C above. - const args = process.argv.slice(2); - const nonFlags = args.filter((t) => !t.startsWith("-")); - const routeContext = nonFlags[0] ?? ""; - const suggestion = getCommandSuggestion(routeContext, input); - if (suggestion) { - const hint = suggestion.explanation - ? `${suggestion.explanation}: ${suggestion.command}` - : suggestion.command; - // Stricli wraps our return value in bold-red ANSI codes. - // Reset before applying warning() color so the tip is yellow, not red. - const formatted = ansiColor - ? `\n\x1B[39m\x1B[22m${warning(`Tip: ${hint}`)}` - : `\nTip: ${hint}`; - return `${base}${formatted}`; - } - - return base; - }, - exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => { - // `boundArguments` positionals parse in the command wrapper, after - // Stricli's scanner; their errors still read as argument errors - if (exc instanceof ArgumentScannerError) { - return customText.exceptionWhileParsingArguments(exc, ansiColor); - } - - // These errors are handled outside Stricli: OutputError has already been - // rendered, while auth errors may trigger login and a single retry. - if (escapesToOuterMiddleware(exc)) { - throw exc; - } - - // Case C: With defaultCommand: "view", unknown tokens like "metrics" are - // silently consumed as the positional arg. The view command fails at the - // domain level (e.g., ResolutionError). Check argv for a known synonym - // and show the suggestion — skip Sentry capture since these are known - // user mistakes, not real errors. - const synonymResult = formatSynonymError(exc, ansiColor); - if (synonymResult) { - return redactCredentialText(synonymResult); - } - - // Report command errors to Sentry with stable fingerprinting. Stricli - // catches exceptions and doesn't re-throw, so we must capture here to - // get visibility into command failures. Silencing rules (OutputError, - // expected AuthError, 401–499 ApiError) and fingerprint normalization - // are enforced inside reportCliError. 400 Bad Request = CLI bug. - reportCliError(exc); - - if (exc instanceof CliError) { - // WizardError with rendered=true: clack already displayed the error. - // Return empty string to avoid double output, exit code flows through. - if (exc instanceof WizardError && exc.rendered) { - return ""; - } - const prefix = ansiColor ? errorColor("Error:") : "Error:"; - return `${prefix} ${formatError(exc)}`; - } - if (exc instanceof Error) { - return `Unexpected error: ${redactCredentialText(exc.stack ?? exc.message)}`; - } - return `Unexpected error: ${formatError(exc)}`; - }, -}; +export const app = buildCliApplication(routes, { + pluralAliases: PLURAL_TO_SINGULAR, +}); -export const app = buildApplication(routes, { - name: "sentry", - versionInfo: { - currentVersion: CLI_VERSION, - }, - scanner: { - caseStyle: "allow-kebab-for-camel", - // Allow `--` to stop flag parsing so wrapper commands (e.g. - // `sentry monitor run -- `) can pass through flags - // like `-e` or `--verbose` to the wrapped command unambiguously. - allowArgumentEscapeSequence: true, - // Recognize global flags placed before the subcommand - // (`sentry --verbose issue list`) at any route depth and forward them to - // the leaf command, via our @stricli/core route-scanner patch. Derived - // from GLOBAL_FLAGS so adding a global flag there is all that's needed. - topLevelFlags: buildTopLevelFlags(), - }, - determineExitCode: getExitCode, - documentation: { - // Pluggable help renderer (via our @stricli/core patch): render `--help - // --json` as structured JSON through the same introspection path as - // `sentry help --json`, instead of Stricli's text usage. Returns undefined - // for non-JSON help so the built-in text output is unchanged. This replaces - // the old argv `--help --json` → `help` command rewrite. - renderHelp: ({ prefix, unprocessedInputs }) => - renderJsonHelp(prefix, unprocessedInputs), - }, - localization: { - loadText: () => customText, - }, +// Help shows the kitty/sixel image banner on capable terminals. Registered +// here rather than imported by `lib/help.ts`, so applications without it +// (`@sentry/mcp-server`) leave the image code out of their bundle. +setGraphicsBanner({ + render: graphicsBanner, + extraRows: graphicsBannerExtraRows, }); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 01291a6ce..5403ff1f7 100755 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -3,25 +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 { wrapCall } from "./lib/react-native/wrap-call.js"; +import { installStreamErrorHandlers } from "./lib/stream-errors.js"; +import { SENTRY_CLI } from "./sentry-cli.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 @@ -31,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(); // Shell completion runs on every TAB press: load only the completion engine, // never the full CLI (`@sentry/node`, the command tree). @@ -61,7 +34,7 @@ if (process.argv[2] === "__complete") { } else { // startCli handles its own errors — this is a safety net for truly unexpected rejections import("./cli.js") - .then(({ startCli }) => startCli()) + .then(({ startCli }) => startCli(SENTRY_CLI)) .catch(() => { process.exitCode = 1; }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 73f8accc1..8123d6903 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,72 +3,39 @@ * middleware, and error recovery. Shell completion (`__complete`) never gets * here: the bin entry points run `lib/complete.ts` without loading this module. * + * The runner does not know which commands exist: each binary passes a + * {@link CliEntry} that loads its own application. `sentry` (`bin.ts`, + * `index.ts`) runs every command; `@sentry/mcp-server` (`mcp-server.ts`) runs + * `mcp`, `auth`, and `help`, so its bundle leaves the rest out. + * * Extracted from `bin.ts` so the logic is testable and reusable without * top-level side effects. `bin.ts` remains a thin wrapper that registers * stream error handlers and calls `startCli()`. */ +import type { CliApplication } from "./lib/application.js"; 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 type { CliRoutes } from "./lib/route-registry.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. - const { captureEnvTokenHost } = await import("./lib/env-token-host.js"); - captureEnvTokenHost(); - - 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 and the logger - 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 - } - } -} +/** What a binary runs: its application. */ +export type CliEntry = { + /** Load the application. Loading it registers its routes. */ + readonly loadApp: () => Promise<{ + app: CliApplication; + routes: CliRoutes; + }>; +}; /** * Flags whose values must never be sent to telemetry. * Superset of `SENSITIVE_FLAGS` in `telemetry.ts` — includes `auth-token` * because raw argv may use either form before Stricli parses to camelCase. */ -const SENSITIVE_ARGV_FLAGS = new Set(["token", "auth-token"]); +const SENSITIVE_ARGV_FLAGS = new Set(["token", "auth-token", "access-token"]); /** * Check whether an argv token is a sensitive flag that needs value redaction. @@ -213,12 +180,7 @@ type ErrorMiddleware = ( retryArgs: string[], ) => Promise; -/** - * Return MCP's arguments when it is the command after leading global flags. - * - * MCP owns stdout for JSON-RPC, so CLI-level output flags are deliberately - * ignored before it starts. Flags after `mcp` belong to the MCP server. - */ +/** Index of the first token after a leading global flag, if `index` is one. */ function skipLeadingGlobalFlag( cliArgs: readonly string[], index: number, @@ -238,69 +200,115 @@ function skipLeadingGlobalFlag( : index + 2; } -export function getMcpArgs(cliArgs: readonly string[]): string[] | undefined { +/** + * Whether `cliArgs` runs the `mcp` command: `mcp` after any leading global + * flags, or no command at all when `mcp` is the default (`@sentry/mcp-server`). + * + * The stdio server owns stdout for JSON-RPC and reports its own telemetry, so + * the runner starts it without the CLI's telemetry, recovery middleware, and + * update check. + */ +export function isMcpInvocation( + routes: CliRoutes, + cliArgs: readonly string[], +): boolean { + const mcp = routes.getRoutingTargetForInput("mcp"); + if (!mcp) { + return false; + } + const isDefault = routes.getDefaultCommand() === mcp; for (let index = 0; index < cliArgs.length;) { const token = cliArgs[index] ?? ""; - if (token === "--") { - return; - } if (!token.startsWith("-")) { - return token === "mcp" ? cliArgs.slice(index + 1) : undefined; + return token === "mcp"; } - const nextIndex = skipLeadingGlobalFlag(cliArgs, index); if (nextIndex === undefined) { - return; + // `--` or a flag that only a command takes: the default command's. + return isDefault; } index = nextIndex; } - - return; + return isDefault; } -/** Run MCP and return whether the current invocation was handled by it. */ -export async function runMcpCommand(cliArgs: string[]): Promise { - const mcpArgs = getMcpArgs(cliArgs); - if (!mcpArgs) { - return false; - } +/** + * Global output flags that `mcp` does not declare: it has no `output` config, + * so `buildCommand` does not inject them, and stdout carries JSON-RPC. + */ +const MCP_IGNORED_OUTPUT_FLAGS = new Set(["--json", "--no-json", "--fields"]); - const [{ startMcpServer }, { getExitCode }] = await Promise.all([ - import("./lib/mcp.js"), - import("./lib/errors.js"), - ]); +/** + * Drop global output flags placed before the command, so `sentry --json mcp` + * starts the server instead of failing to parse. Flags after `mcp` belong to + * the server and are kept. + */ +function dropLeadingOutputFlags(cliArgs: readonly string[]): string[] { + const kept: string[] = []; + let index = 0; + while (index < cliArgs.length) { + const token = cliArgs[index] ?? ""; + const nextIndex = token.startsWith("-") + ? skipLeadingGlobalFlag(cliArgs, index) + : undefined; + if (nextIndex === undefined) { + break; + } + if (!MCP_IGNORED_OUTPUT_FLAGS.has(token.split("=", 1)[0] ?? token)) { + kept.push(...cliArgs.slice(index, nextIndex)); + } + index = nextIndex; + } + return [...kept, ...cliArgs.slice(index)]; +} +/** + * Run the stdio MCP server. The `mcp` command resolves once the client + * disconnects, so the teardown below never runs under a live server. + */ +async function runMcpServer( + app: CliApplication, + cliArgs: string[], +): Promise { + const { run } = await import("@stricli/core"); + const { buildContext } = await import("./context.js"); + const { getExitCode } = await import("./lib/errors.js"); + const { error } = await import("./lib/formatters/colors.js"); + const { splitInlineFlagValues } = await import("./lib/mcp/args.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"), - ]); + await run( + app, + splitInlineFlagValues(dropLeadingOutputFlags(cliArgs)), + buildContext(process), + ); + } catch (err) { + // Auth errors escape Stricli for the CLI's login middleware, which the + // server does not use: it runs its own login fallback in a terminal. + process.stderr.write(`${error("Error:")} ${formatError(err)}\n`); + process.exitCode = getExitCode(err); + } finally { + const { scheduleForceExit } = await import("./lib/force-exit.js"); + const { closeGlobalDispatcher } = await import("./lib/close-dispatcher.js"); scheduleForceExit(); await closeGlobalDispatcher(); } - - return true; } /** * Full CLI execution with telemetry, middleware, and error recovery. */ -export async function runCli(cliArgs: string[]): Promise { - if (await runMcpCommand(cliArgs)) { +export async function runCli( + cliArgs: string[], + entry: CliEntry, +): Promise { + const { app, routes } = await entry.loadApp(); + if (isMcpInvocation(routes, cliArgs)) { + await runMcpServer(app, cliArgs); return; } const { isatty } = await import("node:tty"); const { ExitCode, run } = await import("@stricli/core"); - const { app } = await import("./app.js"); const { buildContext } = await import("./context.js"); const { AuthError, OutputError, getExitCode } = await import("./lib/errors.js"); @@ -605,7 +613,6 @@ export async function runCli(cliArgs: string[]): Promise { async function reportUnknownCommand(argv: string[]): Promise { const Sentry = await import("@sentry/node"); const { resolveCommandPath } = await import("./lib/introspect.js"); - const { routes } = await import("./app.js"); const { getDefaultOrganization } = await import("./lib/db/defaults.js"); // Strip flags so resolveCommandPath only sees command path segments @@ -730,7 +737,7 @@ export async function runCli(cliArgs: string[]): Promise { * Reads `process.argv`, runs the full CLI, and handles fatal errors. * Called from `bin.ts`. */ -export async function startCli(): Promise { +export async function startCli(entry: CliEntry): Promise { const args = process.argv.slice(2); // Repair the timezone before anything formats a Date. SEA binaries can fall @@ -754,7 +761,7 @@ export async function startCli(): Promise { // Gracefully degrade: project context is optional. } - return await runCli(args).catch((err) => { + return await runCli(args, entry).catch((err) => { process.stderr.write(`Fatal: ${redactCredentialText(String(err))}\n`); process.exitCode = 1; }); diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index b16584884..7b5784f59 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -17,7 +17,7 @@ import { formatOrgTokenIdentity, formatUserIdentity, type OrgTokenIdentity, -} from "../../lib/formatters/index.js"; +} from "../../lib/formatters/human.js"; import { formatAccountLabel } from "../../lib/formatters/accounts.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 1d766e480..0644ec0bb 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -1,22 +1,133 @@ +/** + * `sentry mcp`: run the local Sentry MCP server over stdio. + * + * Also the default command of the `@sentry/mcp-server` package, so + * `npx @sentry/mcp-server` starts the same server. The server stack (mcp-core, + * the embedded agent providers, `@sentry/node`'s MCP telemetry) loads only + * when the server starts, not for `--help`. + */ + import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; -import { CommandOutput } from "../lib/formatters/output.js"; +import type { McpFlags } from "../lib/mcp/args.js"; + +/** + * mcp-core's `ALL_SKILLS`, copied so the command tree does not import + * mcp-core: docs generation and `pnpm run cli` load every command without + * building it. A test fails if this list drifts. + */ +export const MCP_SKILL_NAMES = [ + "inspect", + "seer", + "docs", + "triage", + "project-management", +] as const; + +/** An optional string flag. */ +function stringFlag(brief: string) { + return { kind: "parsed", parse: String, brief, optional: true } as const; +} + +/** A boolean flag that defaults to off. */ +function booleanFlag(brief: string) { + return { kind: "boolean", brief, default: false } as const; +} -/** Documentation route for the stdio handoff in {@link runCli}. */ export const mcpCommand = buildCommand({ + // The server resolves its own credential (the CLI session or env token), + // with its own `.sentryclirc` URL check. auth: false, + skipRcUrlCheck: true, docs: { brief: "Start a local Sentry MCP server", fullDescription: - "Start the local stdio MCP server using the current Sentry CLI session. " + - "Configure an MCP client with `sentry mcp`; authenticate first with `sentry auth login`.", + "Serve the Sentry MCP tools over stdio, for MCP clients such as Claude Code, Cursor, or VS Code. " + + "Uses the current Sentry CLI login, refreshing it as needed, and stays on the account it started with. " + + "Log in with 'auth login' first, or set SENTRY_AUTH_TOKEN. " + + "A stored login wins over SENTRY_AUTH_TOKEN unless SENTRY_FORCE_ENV_TOKEN=1 is set. " + + "An org auth token (sntrys_) only works with the host it was issued for.\n\n" + + "--access-token and SENTRY_ACCESS_TOKEN are deprecated: they act as SENTRY_AUTH_TOKEN " + + "with SENTRY_FORCE_ENV_TOKEN=1, sent to the --host/--url target, and print a warning.\n\n" + + "Environment variables:\n" + + " SENTRY_AUTH_TOKEN Sentry token (same as for other commands)\n" + + " SENTRY_FORCE_ENV_TOKEN Set to 1 to use SENTRY_AUTH_TOKEN over a stored login\n" + + " SENTRY_HOST, SENTRY_URL Sentry instance (same as for other commands)\n" + + " MCP_URL MCP server URL reported in telemetry\n" + + " SENTRY_DSN DSN for the server's own telemetry\n" + + " OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY\n" + + " LLM API key for AI-powered search tools\n" + + " OPENAI_MODEL, ANTHROPIC_MODEL, OPENROUTER_MODEL\n" + + " LLM model overrides\n" + + " OPENROUTER_REASONING_EFFORT\n" + + " none|minimal|low|medium|high|xhigh, or empty to omit (default: high)\n" + + " EMBEDDED_AGENT_PROVIDER openai, azure-openai, anthropic, or openrouter\n" + + " MCP_SKILLS Skills to grant (comma-separated)\n" + + " MCP_DISABLE_SKILLS Skills to remove (comma-separated)\n\n" + + `Available skills: ${MCP_SKILL_NAMES.join(", ")}`, + examples: [ + { + description: "Start the server with the current CLI login", + command: "sentry mcp", + }, + { + description: "Grant every skill, including seer on self-hosted", + command: "sentry mcp --all-skills", + }, + { + description: "Grant only some skills", + command: "sentry mcp --skills inspect,triage", + }, + { + description: "Use a self-hosted instance over plain HTTP", + command: "sentry mcp --host sentry.internal:9000 --insecure-http", + }, + { + description: "Pick the LLM provider for AI-powered search", + command: "sentry mcp --agent-provider anthropic", + }, + ], + }, + parameters: { + flags: { + "access-token": stringFlag( + "Deprecated: set SENTRY_AUTH_TOKEN (and SENTRY_FORCE_ENV_TOKEN=1) instead", + ), + host: stringFlag("Sentry host for self-hosted instances"), + url: stringFlag("Sentry URL (https only; use --host for http)"), + "insecure-http": booleanFlag( + "Use http:// for a self-hosted --host value", + ), + "organization-slug": stringFlag("Limit every call to this organization"), + "project-slug": stringFlag("Limit calls to this project"), + skills: stringFlag( + "Skills to grant, comma-separated (default: active skills; seer is left out on self-hosted)", + ), + "all-skills": booleanFlag( + "Grant every active skill, including seer on self-hosted", + ), + "disable-skills": stringFlag("Skills to remove, comma-separated"), + experimental: booleanFlag( + "Enable forward-looking tool variants and experimental features", + ), + "agent-provider": stringFlag( + "LLM provider for AI-powered search: openai, azure-openai, anthropic, or openrouter", + ), + "openai-base-url": stringFlag("OpenAI API base URL"), + "openai-model": stringFlag("OpenAI model (default: gpt-5)"), + "anthropic-base-url": stringFlag("Anthropic API base URL"), + "anthropic-model": stringFlag( + "Anthropic model (default: claude-sonnet-4-5)", + ), + "mcp-url": stringFlag("MCP server URL reported in telemetry"), + "sentry-dsn": stringFlag("DSN for the server's own telemetry"), + }, }, - output: { human: (message: string) => message }, - parameters: {}, - // biome-ignore lint/suspicious/useAwait: async generator required by buildCommand - async *func(this: SentryContext) { - yield new CommandOutput( - "The local MCP server is started by running `sentry mcp` from an MCP client configuration.", - ); + // oxlint-disable-next-line require-yield -- The server speaks JSON-RPC on stdout; the command yields no output. + async *func(this: SentryContext, flags: McpFlags) { + const { serveMcp } = await import("../lib/mcp.js"); + // Resolves when the client disconnects, so the runner tears down only + // after the server is done. + await serveMcp(flags, this.cwd); }, }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ad9a20e46..ad6dcc6ef 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -22,8 +22,10 @@ * @module */ +import { startCli } from "./cli.js"; import { buildInvoker, buildRunner } from "./lib/sdk-invoke.js"; import { createSDKMethods } from "./sdk.generated.js"; +import { SENTRY_CLI } from "./sentry-cli.js"; export type { AsyncChannel } from "./lib/async-channel.js"; // Re-export public types and error class from the shared module. @@ -64,4 +66,7 @@ export function createSentrySDK( export default createSentrySDK; // CLI runner — internal, used by dist/bin.cjs wrapper -export { startCli as _cli } from "./cli.js"; +/** @internal Run the full `sentry` CLI on `process.argv`. */ +export function _cli(): Promise { + return startCli(SENTRY_CLI); +} diff --git a/packages/cli/src/lib/account-selection.ts b/packages/cli/src/lib/account-selection.ts index eb08ed3f1..49cac0584 100644 --- a/packages/cli/src/lib/account-selection.ts +++ b/packages/cli/src/lib/account-selection.ts @@ -4,7 +4,6 @@ * and keep the default URL pointed at the active account's server. */ -import { isatty } from "node:tty"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { type AccountSummary, @@ -16,7 +15,7 @@ import { import { getDefaultUrl, setDefaultUrl } from "./db/defaults.js"; import { ContextError, ResolutionError } from "./errors.js"; import { formatAccountLabel } from "./formatters/accounts.js"; -import { interactivePromptsAllowed } from "./interactive-prompts.js"; +import { canShowSelectPrompt, selectWithFilter } from "./select-prompt.js"; import { logger } from "./logger.js"; const log = logger.withTag("auth.accounts"); @@ -112,14 +111,13 @@ export async function promptForAccount( message: string, command: string, ): Promise { - if (!(interactivePromptsAllowed() && isatty(0) && isatty(1))) { + if (!canShowSelectPrompt()) { throw new ContextError( "Account", `${command} `, accounts.map(describeAccount), ); } - const { selectWithFilter } = await import("./init/ui/ink-ui.js"); const selected = await selectWithFilter({ message, options: accounts.map((a) => ({ diff --git a/packages/cli/src/lib/app-identity.ts b/packages/cli/src/lib/app-identity.ts new file mode 100644 index 000000000..aaeb5a3c7 --- /dev/null +++ b/packages/cli/src/lib/app-identity.ts @@ -0,0 +1,69 @@ +/** + * Which program is running, so hints name a command the user can run. + * + * The same code ships as the `sentry` CLI and as the `@sentry/mcp-server` + * package (`sentry-mcp`). Each entry point registers its identity before it + * starts; library code reads it here instead of assuming `sentry`. + */ + +import { getEnv } from "./env.js"; + +export type AppIdentity = { + /** The binary name, as installed on PATH. */ + readonly name: string; + /** The npm package that ships the binary. */ + readonly packageName: string; +}; + +let identity: AppIdentity = { name: "sentry", packageName: "sentry" }; + +/** Register the running program. Called by entry points before startup. */ +export function setAppIdentity(next: AppIdentity): void { + identity = next; +} + +/** The running program; the `sentry` CLI unless an entry point said otherwise. */ +export function getAppIdentity(): AppIdentity { + return identity; +} + +/** + * `command` as the running program spells it: a leading `sentry ` (how + * command docs write their examples) becomes this program's name. + */ +export function asRunningAppCommand(command: string): string { + return command.startsWith("sentry ") + ? `${identity.name}${command.slice("sentry".length)}` + : command; +} + +/** + * Whether npm's `npx` (`npm exec`) launched this process. The binary is then + * not on PATH, so hints must go through `npx` too. + */ +function launchedThroughNpx(): boolean { + return ( + getEnv().npm_command === "exec" || + /[\\/]_npx[\\/]/.test(process.argv[1] ?? "") + ); +} + +/** + * The command that runs this program again: the binary name, or + * `npx -y ` when `npx` launched it. + */ +export function getInvocationCommand(): string { + return launchedThroughNpx() + ? `npx -y ${identity.packageName}` + : identity.name; +} + +/** Command that signs in to the active Sentry host. */ +export function getLoginCommand(): string { + return `${getInvocationCommand()} auth login`; +} + +/** Command that signs in and confirms a self-hosted host (append the URL). */ +export function getUrlLoginCommand(): string { + return `${getLoginCommand()} --url`; +} diff --git a/packages/cli/src/lib/application.ts b/packages/cli/src/lib/application.ts new file mode 100644 index 000000000..0c5f51a6f --- /dev/null +++ b/packages/cli/src/lib/application.ts @@ -0,0 +1,353 @@ +/** + * Build a Stricli application for a CLI route map. + * + * The `sentry` CLI (`app.ts`) and the `@sentry/mcp-server` bundle + * (`mcp-server-app.ts`) run different route maps through the same runner + * (`cli.ts`). Both get the same scanner settings, error text, and help + * rendering from here. Building an application also registers its routes, so + * library code (help, completions, list commands) sees the commands that are + * actually available. + */ + +import { + type Application, + type ApplicationText, + ArgumentScannerError, + buildApplication, + text_en, + UnexpectedPositionalError, + UnsatisfiedPositionalError, +} from "@stricli/core"; +import type { SentryContext } from "../context.js"; +import { getAppIdentity } from "./app-identity.js"; +import { + getCommandSuggestion, + getSynonymSuggestionFromArgv, +} from "./command-suggestions.js"; +import { CLI_VERSION } from "./constants.js"; +import { redactCredentialText } from "./credential-redaction.js"; +import { reportArgumentError, reportCliError } from "./error-reporting.js"; +import { + ApiError, + AuthError, + CliError, + formatError, + getExitCode, + OutputError, + WizardError, +} from "./errors.js"; +import { error as errorColor, warning } from "./formatters/colors.js"; +import { stripAnsi } from "./formatters/plain-detect.js"; +import { buildTopLevelFlags } from "./global-flags.js"; +import { renderJsonHelp } from "./help.js"; +import { isRouteMap, type RouteMap } from "./introspect.js"; +import { type CliRoutes, setRoutes } from "./route-registry.js"; + +/** A Stricli application over the CLI's context. */ +export type CliApplication = Application; + +type ArgumentHintOptions = { + readonly routesWithDefaultCommand: ReadonlySet; + readonly pluralAliases: Readonly>; +}; + +export type CliApplicationOptions = { + /** + * Plural route aliases mapped to their singular route, for the + * "Did you mean" hint when an alias gets extra arguments. + */ + readonly pluralAliases?: Readonly>; +}; + +/** + * Route group names that have `defaultCommand` set. + * + * Derived from the route map — no manual list to maintain. + * Used to detect the no-args case (`sentry issue` with no subcommand) + * so we can show a usage hint instead of a confusing parse error. + */ +function findRoutesWithDefaultCommand(routes: CliRoutes): ReadonlySet { + return new Set( + routes + .getAllEntries() + .filter( + (e) => + isRouteMap(e.target as unknown) && + (e.target as unknown as RouteMap).getDefaultCommand?.(), + ) + .map((e) => e.name.original), + ); +} + +/** + * Detect when the user typed a bare route group with no subcommand (e.g., `sentry issue`). + * + * With `defaultCommand: "view"` on route groups, Stricli routes to the view + * command which then fails with UnsatisfiedPositionalError because no issue ID + * was provided. Returns a usage hint string, or undefined if this isn't the + * bare-route-group case. + */ +function detectBareRouteGroup( + routesWithDefaultCommand: ReadonlySet, + ansiColor: boolean, +): string | undefined { + const args = process.argv.slice(2); + const nonFlags = args.filter((t) => !t.startsWith("-")); + if ( + nonFlags.length <= 1 && + nonFlags[0] && + routesWithDefaultCommand.has(nonFlags[0]) + ) { + const route = nonFlags[0]; + const { name } = getAppIdentity(); + const msg = `Usage: ${name} ${route} [args]\nRun "${name} ${route} --help" to see available commands`; + return ansiColor ? warning(msg) : msg; + } + return; +} + +/** + * Detect when a plural alias received extra positional args and suggest the + * singular form. E.g., `sentry projects view cli` → `sentry project view cli`. + */ +function detectPluralAliasMisuse( + pluralAliases: Readonly>, + ansiColor: boolean, +): string | undefined { + const args = process.argv.slice(2); + const firstArg = args[0]; + if (firstArg && firstArg in pluralAliases) { + const singular = pluralAliases[firstArg]; + const rest = args.slice(1).join(" "); + const hint = `\nDid you mean: ${getAppIdentity().name} ${singular} ${rest}\n`; + return ansiColor ? warning(hint) : hint; + } + return; +} + +/** + * Format a CliError with a synonym suggestion when the user typed a known + * synonym that was consumed as a positional arg by `defaultCommand: "view"`. + * + * Returns the formatted error string if a synonym match is found, + * undefined otherwise. Skips Sentry capture for these known user mistakes. + */ +function formatSynonymError( + exc: unknown, + ansiColor: boolean, +): string | undefined { + if (!(exc instanceof CliError)) { + return; + } + const synonymHint = getSynonymSuggestionFromArgv(); + if (!synonymHint) { + return; + } + const prefix = ansiColor ? errorColor("Error:") : "Error:"; + const tip = ansiColor + ? warning(`Tip: ${synonymHint}`) + : `Tip: ${synonymHint}`; + return `${prefix} ${exc.format()}\n${tip}`; +} + +function escapesToOuterMiddleware(exc: unknown): boolean { + if (exc instanceof OutputError) { + return true; + } + if (exc instanceof AuthError) { + return exc.reason === "not_authenticated" || exc.reason === "expired"; + } + return exc instanceof ApiError && (exc.status === 401 || exc.status === 403); +} + +/** Message for an argument that failed to parse, with our hints. */ +function formatArgumentError( + options: ArgumentHintOptions, + exc: unknown, + ansiColor: boolean, +): string { + // Case A: bare route group with no subcommand (e.g., `sentry issue`) + if (exc instanceof UnsatisfiedPositionalError) { + const bareHint = detectBareRouteGroup( + options.routesWithDefaultCommand, + ansiColor, + ); + if (bareHint) { + return bareHint; + } + } + + // Case B + plural alias: extra args that Stricli can't consume + if (exc instanceof UnexpectedPositionalError) { + const pluralHint = detectPluralAliasMisuse( + options.pluralAliases, + ansiColor, + ); + if (pluralHint) { + return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${pluralHint}`; + } + + // With defaultCommand: "view", unknown tokens like "metrics" fill the + // positional slot, then extra args (e.g., CLI-AB) trigger this error. + // Check if the first non-route token is a known synonym. + const synonymHint = getSynonymSuggestionFromArgv(); + if (synonymHint) { + const tip = ansiColor + ? warning(`\nTip: ${synonymHint}`) + : `\nTip: ${synonymHint}`; + return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${tip}`; + } + } + + return text_en.exceptionWhileParsingArguments(exc, ansiColor); +} + +/** + * Custom error formatting for CLI errors. + * + * - AuthError (not_authenticated): Re-thrown to allow auto-login flow in bin.ts + * - Other CliError subclasses: Show clean user-friendly message without stack trace + * - Other errors: Show stack trace for debugging unexpected issues + */ +function buildApplicationText(options: ArgumentHintOptions): ApplicationText { + const customText: ApplicationText = { + ...text_en, + exceptionWhileParsingArguments: ( + exc: unknown, + ansiColor: boolean, + ): string => { + const message = formatArgumentError(options, exc, ansiColor); + // Exit 252 never reaches reportCliError; record it for UX research + reportArgumentError(exc, stripAnsi(message)); + return message; + }, + noCommandRegisteredForInput: ({ + input, + corrections, + ansiColor, + }): string => { + // Default error message from Stricli (e.g., "No command registered for `info`") + const base = text_en.noCommandRegisteredForInput({ + input, + corrections, + ansiColor, + }); + + // Check for known synonym suggestions on routes without defaultCommand + // (e.g., `sentry cli info` → suggest `sentry auth status`). + // Routes WITH defaultCommand won't reach here — their unknown tokens + // are consumed as positional args and handled by Cases A/B/C above. + const args = process.argv.slice(2); + const nonFlags = args.filter((t) => !t.startsWith("-")); + const routeContext = nonFlags[0] ?? ""; + const suggestion = getCommandSuggestion(routeContext, input); + if (suggestion) { + const hint = suggestion.explanation + ? `${suggestion.explanation}: ${suggestion.command}` + : suggestion.command; + // Stricli wraps our return value in bold-red ANSI codes. + // Reset before applying warning() color so the tip is yellow, not red. + const formatted = ansiColor + ? `\n\x1B[39m\x1B[22m${warning(`Tip: ${hint}`)}` + : `\nTip: ${hint}`; + return `${base}${formatted}`; + } + + return base; + }, + exceptionWhileRunningCommand: ( + exc: unknown, + ansiColor: boolean, + ): string => { + // `boundArguments` positionals parse in the command wrapper, after + // Stricli's scanner; their errors still read as argument errors + if (exc instanceof ArgumentScannerError) { + return customText.exceptionWhileParsingArguments(exc, ansiColor); + } + + // These errors are handled outside Stricli: OutputError has already been + // rendered, while auth errors may trigger login and a single retry. + if (escapesToOuterMiddleware(exc)) { + throw exc; + } + + // Case C: With defaultCommand: "view", unknown tokens like "metrics" are + // silently consumed as the positional arg. The view command fails at the + // domain level (e.g., ResolutionError). Check argv for a known synonym + // and show the suggestion — skip Sentry capture since these are known + // user mistakes, not real errors. + const synonymResult = formatSynonymError(exc, ansiColor); + if (synonymResult) { + return redactCredentialText(synonymResult); + } + + // Report command errors to Sentry with stable fingerprinting. Stricli + // catches exceptions and doesn't re-throw, so we must capture here to + // get visibility into command failures. Silencing rules (OutputError, + // expected AuthError, 401–499 ApiError) and fingerprint normalization + // are enforced inside reportCliError. 400 Bad Request = CLI bug. + reportCliError(exc); + + if (exc instanceof CliError) { + // WizardError with rendered=true: clack already displayed the error. + // Return empty string to avoid double output, exit code flows through. + if (exc instanceof WizardError && exc.rendered) { + return ""; + } + const prefix = ansiColor ? errorColor("Error:") : "Error:"; + return `${prefix} ${formatError(exc)}`; + } + if (exc instanceof Error) { + return `Unexpected error: ${redactCredentialText(exc.stack ?? exc.message)}`; + } + return `Unexpected error: ${formatError(exc)}`; + }, + }; + return customText; +} + +/** + * Build the application for `routes`, named after the running program + * ({@link getAppIdentity}), and register `routes` with the route registry. + */ +export function buildCliApplication( + routes: CliRoutes, + options: CliApplicationOptions = {}, +): CliApplication { + setRoutes(routes); + const text = buildApplicationText({ + routesWithDefaultCommand: findRoutesWithDefaultCommand(routes), + pluralAliases: options.pluralAliases ?? {}, + }); + return buildApplication(routes, { + name: getAppIdentity().name, + versionInfo: { + currentVersion: CLI_VERSION, + }, + scanner: { + caseStyle: "allow-kebab-for-camel", + // Allow `--` to stop flag parsing so wrapper commands (e.g. + // `sentry monitor run -- `) can pass through flags + // like `-e` or `--verbose` to the wrapped command unambiguously. + allowArgumentEscapeSequence: true, + // Recognize global flags placed before the subcommand + // (`sentry --verbose issue list`) at any route depth and forward them to + // the leaf command, via our @stricli/core route-scanner patch. Derived + // from GLOBAL_FLAGS so adding a global flag there is all that's needed. + topLevelFlags: buildTopLevelFlags(), + }, + determineExitCode: getExitCode, + documentation: { + // Pluggable help renderer (via our @stricli/core patch): render `--help + // --json` as structured JSON through the same introspection path as + // `sentry help --json`, instead of Stricli's text usage. Returns undefined + // for non-JSON help so the built-in text output is unchanged. This replaces + // the old argv `--help --json` → `help` command rewrite. + renderHelp: ({ prefix, unprocessedInputs }) => + renderJsonHelp(prefix, unprocessedInputs), + }, + localization: { + loadText: () => text, + }, + }); +} diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 840300dcf..c4706a79c 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -38,6 +38,7 @@ import { numberParser as stricliNumberParser, } from "@stricli/core"; import type { Writer } from "../types/index.js"; +import { asRunningAppCommand } from "./app-identity.js"; import { appendCacheHint } from "./cache-hint.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { getAuthConfig, hasUsableStoredAccount } from "./db/auth.js"; @@ -457,7 +458,7 @@ function prepareNativeDocs( } const rendered = examples .map((example) => { - const steps = exampleSteps(example); + const steps = exampleSteps(example).map(asRunningAppCommand); return steps.length === 1 ? ` ${steps[0]} # ${example.description}` : [` # ${example.description}`, ...steps.map((s) => ` ${s}`)].join( diff --git a/packages/cli/src/lib/custom-headers.ts b/packages/cli/src/lib/custom-headers.ts index 67a7f592f..536ecf07b 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 { getUrlLoginCommand } from "./app-identity.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 '${getUrlLoginCommand()} ' against the intended instance.`, ); } return; diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 2f1507c75..79d1f9dbc 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -67,6 +67,7 @@ import { getDatabase, withDbSpan } from "./index.js"; import { clearCompletionCache } from "./completion-cache.js"; import { clearAllIssueOrgCache } from "./issue-org-cache.js"; import { clearTrustedHostState } from "./regions.js"; +import { getLoginCommand } from "../app-identity.js"; /** Refresh when less than 10% of token lifetime remains */ export const REFRESH_THRESHOLD = 0.1; @@ -1727,7 +1728,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 '${getLoginCommand()}'.`, ); } diff --git a/packages/cli/src/lib/db/migration.ts b/packages/cli/src/lib/db/migration.ts index d57778222..941cb2aa2 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 { getLoginCommand } from "../app-identity.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 '${getLoginCommand()}' 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..f3d121a73 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: + "Deprecated. Auth token for `@sentry/mcp-server` and `sentry mcp` only (the env form of the deprecated `--access-token` flag). It acts as `SENTRY_AUTH_TOKEN` with `SENTRY_FORCE_ENV_TOKEN=1` and prints a deprecation warning; set those instead. 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..909286f91 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 { getLoginCommand, getUrlLoginCommand } from "./app-identity.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: ${getUrlLoginCommand()} ${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: ${getUrlLoginCommand()} ${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 '${getLoginCommand()}' first.`, + expired: `Authentication expired. Run '${getLoginCommand()}' 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 '${getLoginCommand()}' to replace stored credentials.`, ); this.name = "MalformedAuthTokenError"; } diff --git a/packages/cli/src/lib/formatters/highlight.ts b/packages/cli/src/lib/formatters/highlight.ts index 722664edc..8852255f2 100644 --- a/packages/cli/src/lib/formatters/highlight.ts +++ b/packages/cli/src/lib/formatters/highlight.ts @@ -8,8 +8,9 @@ * ways: * * - `cli-highlight` is required on the first code block, not at import. The - * CLI bundle still contains it (script/bundle.ts and script/build.ts list - * this file in `REQUIRE_ALIAS_FILTER`), but only runs it when needed. + * CLI bundle still contains it (script/lib/require-alias-plugin.ts and + * script/build.ts list this file in `REQUIRE_ALIAS_FILTER`), but only runs + * it when needed. * - A bundle that doesn't need colored code blocks, such as the MCP server, * sets `define: { __SENTRY_CLI_SYNTAX_HIGHLIGHT__: "false" }`. esbuild then * drops the `require()` with its dead branch, so highlight.js and parse5 diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index c5f8867f4..3792f2164 100644 --- a/packages/cli/src/lib/help.ts +++ b/packages/cli/src/lib/help.ts @@ -6,6 +6,7 @@ * Commands are auto-generated from Stricli's route structure. */ +import { asRunningAppCommand } from "./app-identity.js"; import { formatBanner } from "./banner.js"; import { isAuthenticated } from "./db/auth.js"; import { detectAgent } from "./detect-agent.js"; @@ -29,7 +30,6 @@ import { resolveCommandPath, } from "./introspect.js"; import { getRoutes } from "./route-registry.js"; -import { graphicsBanner, graphicsBannerExtraRows } from "./sixel.js"; const TAGLINE = "The command-line interface for Sentry"; @@ -282,6 +282,28 @@ function formatEnvVars(): string { }).join("\n"); } +/** + * Renders the help banner as a terminal image (kitty or sixel). + * + * The full CLI registers one from `app.ts` with {@link setGraphicsBanner}. + * Applications that don't, such as `@sentry/mcp-server`, show the block-art + * banner instead and leave the image code (`sixel.ts`, `pngjs`, `jpeg-js`) + * out of their bundle. + */ +export type GraphicsBanner = { + /** The image for `columns`, or undefined when the terminal can't show it. */ + render: (columns: number) => string | undefined; + /** Rows the image occupies beyond its lines of text. */ + extraRows: () => number; +}; + +let graphicsBanner: GraphicsBanner | undefined; + +/** Register the image banner for {@link printCustomHelp}. */ +export function setGraphicsBanner(banner: GraphicsBanner | undefined): void { + graphicsBanner = banner; +} + /** Options for {@link printCustomHelp}. */ type CustomHelpOptions = { /** @@ -311,13 +333,14 @@ export function printCustomHelp(options: CustomHelpOptions = {}): string { // Prefer a real kitty/sixel image on capable terminals; otherwise fall back // to the block-art banner sized to the terminal width (returns "" only when // the terminal is too narrow for even the compact mark). - const image = graphicsBanner(cols); + const graphics = graphicsBanner; + const image = graphics?.render(cols); const banner = image ?? formatBanner(cols); if (banner) { header.push(""); header.push(banner); header.push(""); - imageExtraRows = image ? graphicsBannerExtraRows() : 0; + imageExtraRows = image && graphics ? graphics.extraRows() : 0; } } @@ -836,7 +859,7 @@ function formatCommandHuman(cmd: CommandInfo): string { */ function formatGroupHuman(group: RouteInfo): string { const lines: string[] = []; - lines.push(`sentry ${group.name}`); + lines.push(asRunningAppCommand(`sentry ${group.name}`)); lines.push(""); lines.push(` ${group.brief}`); lines.push(""); diff --git a/packages/cli/src/lib/login-host-guard.ts b/packages/cli/src/lib/login-host-guard.ts index 0ee77f688..2c7e0b478 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 { getUrlLoginCommand } from "./app-identity.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}` + ` ${getUrlLoginCommand()} ${host}${tokenFlag}` ); } diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index e5b8711e8..8996b988f 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -1,69 +1,150 @@ import { normalizeAuthToken } from "./auth-header.js"; import { getConfiguredSentryUrl } from "./constants.js"; -import { refreshToken } from "./db/auth.js"; -import { getEnv } from "./env.js"; -import { HostScopeError, ValidationError } from "./errors.js"; +import { getAccountScope } from "./db/account-scope.js"; +import { accountIdentity, getAccountRow } from "./db/accounts.js"; +import { + getEffectiveAccountRow, + type RefreshTokenResult, + refreshToken, +} from "./db/auth.js"; +import { getEnv, withEnv } from "./env.js"; +import { AuthError, HostScopeError } from "./errors.js"; +import { type McpFlags, mergeMcpArgs, parseMcpEnv } from "./mcp/args.js"; +import { finalizeMcpConfig } from "./mcp/config.js"; +import { parseSntrysClaim } from "./token-claims.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; -type McpServerConfig = { +type McpTarget = { sentryHost: string; sentryProtocol: "http" | "https"; }; -function hasSentryTargetArg(args: readonly string[]): boolean { - return args.some( - (arg) => - arg === "--host" || - arg.startsWith("--host=") || - arg === "--url" || - arg.startsWith("--url="), - ); +function hasSentryTarget(flags: McpFlags): boolean { + return flags.host !== undefined || flags.url !== undefined; } /** * Translate the CLI's URL setting into MCP's host/protocol flags. * - * The MCP parser intentionally rejects insecure SENTRY_URL values, while the - * CLI uses them for self-hosted defaults. Passing explicit flags preserves the - * selected CLI host without letting SENTRY_URL override --insecure-http. + * The MCP config intentionally rejects insecure `--url` values, while the + * CLI uses them for self-hosted defaults. Setting `--host` (plus + * `--insecure-http` for http) keeps the selected CLI host without letting + * SENTRY_URL override --insecure-http. */ -export function prepareMcpServerArgs( - args: readonly string[], +export function withConfiguredTarget( + flags: McpFlags, sentryUrl = getConfiguredSentryUrl(), -): string[] { - if (!sentryUrl || hasSentryTargetArg(args)) { - return [...args]; +): McpFlags { + if (!sentryUrl || hasSentryTarget(flags)) { + return flags; } - // oxlint-disable-next-line sentry-cli/no-silent-catch -- invalid URLs are passed through to the MCP parser for its normal validation error. + // oxlint-disable-next-line sentry-cli/no-silent-catch -- invalid URLs are passed through as --url for the config's normal validation error. try { const url = new URL(sentryUrl); if (url.protocol !== "http:" && url.protocol !== "https:") { - return [...args, `--url=${sentryUrl}`]; + return { ...flags, url: sentryUrl }; } - return [ - ...args, - `--host=${url.host}`, - ...(url.protocol === "http:" ? ["--insecure-http"] : []), - ]; + return { + ...flags, + host: url.host, + "insecure-http": flags["insecure-http"] || url.protocol === "http:", + }; } catch { - return [...args, `--url=${sentryUrl}`]; + return { ...flags, url: sentryUrl }; + } +} + +/** + * 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 +155,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 +181,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 +213,148 @@ async function resolveMcpAccessTokenWithLogin( throw error; } - return await resolveCliMcpAccessToken(config); + return await resolveCliMcpCredential(config); } } -/** Start the local stdio server without introducing a second auth flow. */ -export async function startMcpServer(args: string[]): Promise { - if (args[0] === "auth") { - throw new ValidationError( - "Use `sentry auth` to manage credentials for `sentry mcp`.", - ); +/** A token from the deprecated `--access-token` flag or `SENTRY_ACCESS_TOKEN`. */ +type DeprecatedAccessToken = { + token: string; + source: "--access-token" | "SENTRY_ACCESS_TOKEN"; +}; + +/** The deprecation hint is printed once per process. */ +const deprecationHint = { shown: false }; + +/** @internal */ +export function resetDeprecationHintForTesting(): void { + deprecationHint.shown = false; +} + +/** + * Read the deprecated `--access-token` / `SENTRY_ACCESS_TOKEN`, flag first. + * Blank values count as unset, the same as for `SENTRY_AUTH_TOKEN`. + */ +function readDeprecatedAccessToken( + flags: McpFlags, + env: NodeJS.ProcessEnv, +): DeprecatedAccessToken | undefined { + const flag = flags["access-token"]; + if (flag?.trim()) { + return { token: flag, source: "--access-token" }; + } + const fromEnv = env.SENTRY_ACCESS_TOKEN; + if (fromEnv?.trim()) { + return { token: fromEnv, source: "SENTRY_ACCESS_TOKEN" }; + } + return; +} + +/** Tell the user, on stderr, which setting replaces the deprecated one. */ +function warnDeprecatedAccessToken(source: DeprecatedAccessToken["source"]) { + if (deprecationHint.shown) { + return; + } + deprecationHint.shown = true; + // stderr only: stdout carries the stdio MCP transport. Never the token. + process.stderr.write( + `Deprecation warning: ${source} is deprecated; set SENTRY_AUTH_TOKEN ` + + "(and SENTRY_FORCE_ENV_TOKEN=1 to override a stored login) instead.\n", + ); +} + +/** + * The environment the deprecated token runs under: the same as + * `SENTRY_AUTH_TOKEN= SENTRY_FORCE_ENV_TOKEN=1`, so it takes the CLI's + * env-token path and wins over any stored login. It also replaces an env + * token that is already set, since the flag (or `SENTRY_ACCESS_TOKEN`) is the + * more explicit choice for this server. + * + * The URL is pinned to the MCP target: `--host`/`--url` have always told the + * MCP server where to send this token, and the env-token snapshot for this + * copy then scopes the token to that server. An org auth token's own URL + * claim still wins, so it cannot be sent anywhere else. + */ +function deprecatedTokenEnv( + env: NodeJS.ProcessEnv, + token: string, + target: McpTarget, +): NodeJS.ProcessEnv { + const aliased: NodeJS.ProcessEnv = { ...env }; + delete aliased.SENTRY_ACCESS_TOKEN; + delete aliased.SENTRY_TOKEN; + aliased.SENTRY_AUTH_TOKEN = token; + aliased.SENTRY_FORCE_ENV_TOKEN = "1"; + delete aliased.SENTRY_HOST; + aliased.SENTRY_URL = `${target.sentryProtocol}://${target.sentryHost}`; + return aliased; +} + +/** + * Run the local stdio server without introducing a second auth flow. + * + * Resolves once the client disconnects. + * + * @param cwd - The command context's working directory, where a repo-local + * `.sentryclirc` is looked up. Unlike `process.cwd()`, it survives a deleted + * working directory (see `resolveCwd` in `context.ts`). + */ +export async function serveMcp(flags: McpFlags, cwd: string): Promise { + const env = getEnv(); + const config = finalizeMcpConfig( + mergeMcpArgs(withConfiguredTarget(flags), parseMcpEnv(env)), + ); + const deprecated = readDeprecatedAccessToken(flags, env); + if (deprecated) { + warnDeprecatedAccessToken(deprecated.source); } + // Validate before anything else: a malformed token fails here, and the + // error never repeats it. + const deprecatedToken = deprecated + ? normalizeAuthToken(deprecated.token) + : undefined; - 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, + // A repo-local `.sentryclirc` can set the target URL, but it is not a trust + // source: without this gate, a token with no host claim would be sent to + // whatever server the file names. Apply the same rc-URL check as every + // other CLI command, unless an explicit `--host`/`--url` flag overrides the + // target, or the deprecated token is an org token, whose host claim the + // credential check below matches against the target. Checked against the + // invocation's own credentials, before the deprecated token replaces them. + if ( + !( + hasSentryTarget(flags) || + (deprecatedToken && parseSntrysClaim(deprecatedToken)) + ) + ) { + const { assertRcUrlTrusted } = await import("./sentryclirc.js"); + await assertRcUrlTrusted(cwd); + } + + const credentialEnv = deprecatedToken + ? deprecatedTokenEnv(env, deprecatedToken, config) + : env; + + // Loaded only now: mcp-core's server, the agent providers, and the MCP + // telemetry are not needed for --help or setup errors. + const { serveMcpStdio } = await import("./mcp/server.js"); + const { token, pin } = await withEnv(credentialEnv, () => + resolveMcpCredentialWithLogin(config), + ); + await 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. Requests can arrive + // outside this call's async context, so the credential env is explicit. + refreshAccessToken: () => + withEnv(credentialEnv, async () => { + const refreshed = await resolveCliMcpCredential(config, { + force: true, + pin, + }); + return refreshed.token; + }), + env, }); } diff --git a/packages/cli/src/lib/mcp/args.ts b/packages/cli/src/lib/mcp/args.ts new file mode 100644 index 000000000..068df6d6a --- /dev/null +++ b/packages/cli/src/lib/mcp/args.ts @@ -0,0 +1,137 @@ +/** + * 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. The deprecated `--access-token` / + * `SENTRY_ACCESS_TOKEN` are not read here: `serveMcp` maps them onto the CLI's + * env token (see `lib/mcp.ts`). + */ + +/** `sentry mcp` flags, as Stricli parses them (see `commands/mcp.ts`). */ +export type McpFlags = { + readonly "access-token"?: string; + readonly host?: string; + readonly url?: string; + readonly "insecure-http": boolean; + readonly "mcp-url"?: string; + readonly "sentry-dsn"?: string; + readonly "openai-base-url"?: string; + readonly "openai-model"?: string; + readonly "anthropic-base-url"?: string; + readonly "anthropic-model"?: string; + readonly "agent-provider"?: string; + readonly skills?: string; + readonly "all-skills": boolean; + readonly "disable-skills"?: string; + readonly experimental: boolean; + readonly "organization-slug"?: string; + readonly "project-slug"?: string; +}; + +/** + * Split `--flag=value` into `--flag value` where Stricli does not read the + * joined form: an empty value (`--sentry-dsn=` turns off the server's + * telemetry) or one with a line break (a token pasted with its newline). + * MCP client configurations have always passed both to `@sentry/mcp-server`. + */ +export function splitInlineFlagValues(args: readonly string[]): string[] { + return args.flatMap((arg) => { + const match = /^(--[a-z][a-z-.\d_]*)=(.*)$/is.exec(arg); + const [, flag, value] = match ?? []; + return flag !== undefined && + value !== undefined && + (value === "" || /[\r\n]/.test(value)) + ? [flag, value] + : [arg]; + }); +} + +/** Values read from the environment. */ +export type McpEnvArgs = { + mcpUrl?: string; + sentryDsn?: string; + openaiModel?: string; + anthropicModel?: string; + agentProvider?: string; + skills?: string; + disableSkills?: string; +}; + +/** Flags merged over environment values, before validation. */ +export type McpMergedArgs = { + host?: string; + insecureHttp: boolean; + url?: string; + mcpUrl?: string; + sentryDsn?: string; + openaiBaseUrl?: string; + openaiModel?: string; + anthropicBaseUrl?: string; + anthropicModel?: string; + agentProvider?: string; + /** Whether `--agent-provider` (not the environment) chose the provider. */ + agentProviderFromFlag: boolean; + skills?: string; + allSkills: boolean; + disableSkills?: string; + experimental: boolean; + organizationSlug?: string; + projectSlug?: string; +}; + +/** + * 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.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(flags: McpFlags, env: McpEnvArgs): McpMergedArgs { + return { + url: flags.url, + host: flags.host, + insecureHttp: flags["insecure-http"], + mcpUrl: flags["mcp-url"] ?? env.mcpUrl, + sentryDsn: flags["sentry-dsn"] ?? env.sentryDsn, + openaiBaseUrl: flags["openai-base-url"], + openaiModel: flags["openai-model"] ?? env.openaiModel, + anthropicBaseUrl: flags["anthropic-base-url"], + anthropicModel: flags["anthropic-model"] ?? env.anthropicModel, + agentProvider: flags["agent-provider"] ?? env.agentProvider, + agentProviderFromFlag: flags["agent-provider"] !== undefined, + // --skills and --all-skills both override MCP_SKILLS. + skills: flags.skills ?? (flags["all-skills"] ? undefined : env.skills), + allSkills: flags["all-skills"], + disableSkills: flags["disable-skills"] ?? env.disableSkills, + experimental: flags.experimental, + organizationSlug: flags["organization-slug"], + projectSlug: flags["project-slug"], + }; +} diff --git a/packages/cli/src/lib/mcp/config.ts b/packages/cli/src/lib/mcp/config.ts new file mode 100644 index 000000000..7c1c3ffe1 --- /dev/null +++ b/packages/cli/src/lib/mcp/config.ts @@ -0,0 +1,201 @@ +/** + * 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 = { + 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.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 { + 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, + finalSkills, + organizationSlug: input.organizationSlug, + projectSlug: input.projectSlug, + }; +} diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts new file mode 100644 index 000000000..1d08c4d93 --- /dev/null +++ b/packages/cli/src/lib/mcp/server.ts @@ -0,0 +1,388 @@ +/** + * stdio MCP server host for `sentry mcp`. + * + * 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 { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; +import { startStdio } from "@sentry/mcp-core/transports/stdio"; +import type { ServerContext } from "@sentry/mcp-core/types"; +import { + captureException, + consoleLoggingIntegration, + flush, + init, + makeNodeTransport, + vercelAIIntegration, + zodErrorsIntegration, +} from "@sentry/node"; +import { getAppIdentity } from "../app-identity.js"; +import { CLI_VERSION, SENTRY_CLI_DSN } from "../constants.js"; +import { logger } from "../logger.js"; +import { isTelemetryEnabled } from "../telemetry.js"; +import { redactTelemetryEnvelope } from "../telemetry/credential-redaction.js"; +import type { McpServerConfig } from "./config.js"; + +/** 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; +const log = logger.withTag("mcp.telemetry"); + +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(""); +} + +/** + * Start MCP telemetry. + * + * @internal Exported for testing + */ +export function initMcpTelemetry(config: McpServerConfig): void { + // `init` reads `process.cwd()`, which throws when the working directory was + // deleted. Serve without telemetry instead of crashing at startup. + try { + process.cwd(); + } catch (error) { + // Log only the message: rendering the error's stack calls `process.cwd()`. + log.debug( + `Working directory is unavailable, starting without telemetry: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + const { name, packageName } = getAppIdentity(); + const scopeContext = { + "app.server.version": CLI_VERSION, + // Tells `@sentry/mcp-server` installs apart from `sentry mcp`. + "app.server.distribution": name === "sentry" ? "sentry mcp" : packageName, + "app.transport": "stdio", + "app.upstream.host": config.sentryHost, + "app.url.full": config.mcpUrl, + }; + + // Without `--sentry-dsn`/`SENTRY_DSN`, report to the CLI's own project, + // honoring the CLI's telemetry opt-outs. + const useCliDsn = !config.sentryDsn; + init({ + dsn: config.sentryDsn ?? SENTRY_CLI_DSN, + enabled: !useCliDsn || isTelemetryEnabled(), + 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, + }, + }, + // Always the running release; an env override would misreport it. + release: CLI_VERSION, + // Same credential redaction as the CLI's own telemetry. + transport: (transportOptions) => { + const transport = makeNodeTransport(transportOptions); + return { + send: (envelope) => transport.send(redactTelemetryEnvelope(envelope)), + flush: (timeout) => transport.flush(timeout), + }; + }, + 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. Resolves once the client disconnects. */ +export async function serveMcpStdio( + config: McpServerConfig, + options: McpServeOptions, +): Promise { + configureAgentProvider(config, options.env); + initMcpTelemetry(config); + + 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, + }); + const connection = await startStdio(server, context).catch( + (error: unknown) => { + exitAfterError(error, "Server error:"); + // exitAfterError ends the process; never hand control back. + return new Promise(() => undefined); + }, + ); + await connection.closed; + // Send what the session recorded before the caller releases the network. + await flush(SENTRY_FLUSH_TIMEOUT_MS); +} diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 4fa143a3e..454c7b712 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 { getLoginCommand } from "./app-identity.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 '${getLoginCommand()}' 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 '${getLoginCommand()}' 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..f872228c2 --- /dev/null +++ b/packages/cli/src/lib/project-context.ts @@ -0,0 +1,63 @@ +/** + * 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. + const { captureEnvTokenHost } = await import("./env-token-host.js"); + captureEnvTokenHost(); + + const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([ + import("./dsn/project-root.js"), + import("./db/project-root-cache.js"), + ]); + + // Dynamic import: only boot needs the logger here + const { logger } = await import("./logger.js"); + const log = logger.withTag("project-context"); + + const result = await findProjectRoot(cwd); + // The cache is an optimization; a DB failure must not skip the env shim + // below, or boot would continue without the `.sentryclirc` token and URL. + try { + await setCachedProjectRoot(cwd, { + projectRoot: result.projectRoot, + reason: result.reason, + }); + } catch (error) { + log.debug("Could not cache the project root", error); + } + + // 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 and the logger + 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())) { + 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 (error) { + log.debug("Could not read the persisted default URL", error); + } + } +} diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index b3307918d..61927327f 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -15,7 +15,6 @@ */ import { basename } from "node:path"; -import { isatty } from "node:tty"; import pLimit from "p-limit"; import type { CachedProject, @@ -74,7 +73,7 @@ import { withAuthGuard, } from "./errors.js"; import { fuzzyMatch } from "./fuzzy.js"; -import { interactivePromptsAllowed } from "./interactive-prompts.js"; +import { canShowSelectPrompt, selectWithFilter } from "./select-prompt.js"; import { logger } from "./logger.js"; import { resolveEffectiveOrg } from "./region.js"; import { CONFIG_FILENAME, loadSentryCliRc } from "./sentryclirc.js"; @@ -1805,7 +1804,7 @@ function canPromptForTarget(override?: boolean): boolean { if (override !== undefined) { return override; } - return interactivePromptsAllowed() && isatty(0) && isatty(1); + return canShowSelectPrompt(); } /** @@ -1821,7 +1820,6 @@ async function promptSelect( if (options.length === 1 && sole) { return sole.value; } - const { selectWithFilter } = await import("./init/ui/ink-ui.js"); return await selectWithFilter({ message, options }); } diff --git a/packages/cli/src/lib/select-prompt.ts b/packages/cli/src/lib/select-prompt.ts new file mode 100644 index 000000000..32a1a75ca --- /dev/null +++ b/packages/cli/src/lib/select-prompt.ts @@ -0,0 +1,43 @@ +/** + * The fuzzy-filter select picker (org/project and account selection). + * + * The picker runs on Ink, which the `@sentry/mcp-server` bundle leaves out by + * setting `define: { __SENTRY_CLI_INK_UI__: "false" }`. esbuild then drops + * the picker's import with its dead branch, and callers take the same path as + * in a non-interactive terminal. + */ + +import { isatty } from "node:tty"; +import type { SelectOptions } from "./init/ui/types.js"; +import { interactivePromptsAllowed } from "./interactive-prompts.js"; + +/** Build-time switch; undefined (enabled) outside bundles that define it. */ +declare const __SENTRY_CLI_INK_UI__: boolean | undefined; + +/** + * Whether the picker can run: this build has the Ink UI, prompts are + * allowed, and stdin and stdout are terminals. + */ +export function canShowSelectPrompt(): boolean { + return ( + (typeof __SENTRY_CLI_INK_UI__ === "undefined" || __SENTRY_CLI_INK_UI__) && + interactivePromptsAllowed() && + isatty(0) && + isatty(1) + ); +} + +/** + * Let the user filter and pick one option. Returns the selected value, or + * `null` when the prompt is cancelled. Check {@link canShowSelectPrompt} + * first. + */ +export async function selectWithFilter( + opts: SelectOptions, +): Promise { + if (typeof __SENTRY_CLI_INK_UI__ === "undefined" || __SENTRY_CLI_INK_UI__) { + const ink = await import("./init/ui/ink-ui.js"); + return await ink.selectWithFilter(opts); + } + return null; +} 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/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 26adc8809..454acca1b 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -850,7 +850,7 @@ export function setOrgProjectContext(orgs: string[], projects: string[]): void { * Flag names whose values must never be sent to telemetry. * Values for these flags are replaced with "[REDACTED]" regardless of content. */ -const SENSITIVE_FLAGS = new Set(["token"]); +const SENSITIVE_FLAGS = new Set(["token", "access-token"]); /** * Convert a flag value to a telemetry tag string. diff --git a/packages/cli/src/mcp-server-app.ts b/packages/cli/src/mcp-server-app.ts new file mode 100644 index 000000000..c948d231a --- /dev/null +++ b/packages/cli/src/mcp-server-app.ts @@ -0,0 +1,32 @@ +/** + * The `@sentry/mcp-server` application: `mcp` (the default), `auth`, and + * `help`. + * + * Same commands as the CLI's, so `npx @sentry/mcp-server` starts the server + * and `npx @sentry/mcp-server auth login|logout|status|list|switch` manages + * the session it shares with the CLI. The other CLI commands stay out of the + * bundle. + */ + +import { authRoute } from "./commands/auth/index.js"; +import { helpCommand } from "./commands/help.js"; +import { mcpCommand } from "./commands/mcp.js"; +import { buildCliApplication } from "./lib/application.js"; +import { buildRouteMap } from "./lib/route-map.js"; + +export const routes = buildRouteMap({ + routes: { + mcp: mcpCommand, + auth: authRoute, + help: helpCommand, + }, + defaultCommand: "mcp", + docs: { + brief: "Sentry MCP server over stdio", + fullDescription: + "Run the Sentry MCP server over stdio (the default command), or manage the " + + "Sentry login it shares with the Sentry CLI.", + }, +}); + +export const app = buildCliApplication(routes); diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts new file mode 100644 index 000000000..77c7931a4 --- /dev/null +++ b/packages/cli/src/mcp-server.ts @@ -0,0 +1,39 @@ +/** + * Entry point for the `@sentry/mcp-server` bundle (`sentry-mcp`). + * + * Runs the CLI's runner with a smaller application (`mcp-server-app.ts`), so + * the package gets the real `mcp` and `auth` commands without the rest of + * the CLI. Built by `packages/mcp-server/script/bundle.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 { startCli } from "./cli.js"; +import { setAppIdentity } from "./lib/app-identity.js"; +import { CLI_VERSION } from "./lib/constants.js"; +import { getEnv } from "./lib/env.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(); + +// Before anything formats a hint: they name this package, not `sentry`. +setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); + +// The update check announces CLI releases and `sentry cli upgrade`, which do +// not apply to this package; npm installs its updates. +getEnv().SENTRY_CLI_NO_UPDATE_CHECK = "1"; + +// MCP clients and install checks run a bare `--version`; answer it without +// loading the application and the CLI telemetry it brings. A lone `-v` keeps +// the old package's meaning; anywhere else it is the CLI's `--verbose`. +const versionArg = process.argv.length === 3 ? process.argv[2] : undefined; +if (versionArg === "--version" || versionArg === "-v") { + process.stdout.write(`${CLI_VERSION}\n`); +} else { + startCli({ loadApp: () => import("./mcp-server-app.js") }).catch(() => { + process.exitCode = 1; + }); +} diff --git a/packages/cli/src/sentry-cli.ts b/packages/cli/src/sentry-cli.ts new file mode 100644 index 000000000..52443d199 --- /dev/null +++ b/packages/cli/src/sentry-cli.ts @@ -0,0 +1,14 @@ +/** + * The full `sentry` CLI: every command. + * + * Run by `bin.ts` (standalone binaries) and by `index.ts`'s `_cli` (the npm + * package's `dist/bin.cjs`). Shell completion never gets here: both bins run + * `lib/complete.ts` directly. The `@sentry/mcp-server` bundle runs a smaller + * application instead (`mcp-server.ts`), so nothing there imports this file. + */ + +import type { CliEntry } from "./cli.js"; + +export const SENTRY_CLI: CliEntry = { + loadApp: () => import("./app.js"), +}; diff --git a/packages/cli/test/lib/app-identity.test.ts b/packages/cli/test/lib/app-identity.test.ts new file mode 100644 index 000000000..a4a4dced0 --- /dev/null +++ b/packages/cli/test/lib/app-identity.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { + asRunningAppCommand, + getLoginCommand, + getUrlLoginCommand, + setAppIdentity, +} from "../../src/lib/app-identity.js"; +import { AuthError } from "../../src/lib/errors.js"; +import { useEnvSandbox } from "../helpers.js"; + +useEnvSandbox(["npm_command"]); + +const argv1 = process.argv[1]; + +afterEach(() => { + setAppIdentity({ name: "sentry", packageName: "sentry" }); + process.argv[1] = argv1 ?? ""; +}); + +describe("app identity", () => { + test("hints name the sentry CLI by default", () => { + expect(getLoginCommand()).toBe("sentry auth login"); + expect(new AuthError("not_authenticated").message).toBe( + "Not authenticated. Run 'sentry auth login' first.", + ); + }); + + test("hints name @sentry/mcp-server's binary once it registers", () => { + setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); + expect(getUrlLoginCommand()).toBe("sentry-mcp auth login --url"); + expect(new AuthError("expired").message).toBe( + "Authentication expired. Run 'sentry-mcp auth login' to re-authenticate.", + ); + }); + + test("hints go through npx when npx launched the process", () => { + setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); + process.env.npm_command = "exec"; + expect(getLoginCommand()).toBe("npx -y @sentry/mcp-server auth login"); + + delete process.env.npm_command; + process.argv[1] = "/home/u/.npm/_npx/abc/node_modules/.bin/sentry-mcp"; + expect(getLoginCommand()).toBe("npx -y @sentry/mcp-server auth login"); + }); + + test("rewrites only a leading `sentry ` in example commands", () => { + setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); + expect(asRunningAppCommand("sentry auth login --url x")).toBe( + "sentry-mcp auth login --url x", + ); + expect(asRunningAppCommand("npx sentry auth")).toBe("npx sentry auth"); + expect(asRunningAppCommand("sentry-cli login")).toBe("sentry-cli login"); + }); +}); diff --git a/packages/cli/test/lib/cli-startup.test.ts b/packages/cli/test/lib/cli-startup.test.ts index 59af923e7..1a8ad2cda 100644 --- a/packages/cli/test/lib/cli-startup.test.ts +++ b/packages/cli/test/lib/cli-startup.test.ts @@ -1,5 +1,6 @@ import { expect, test, vi } from "vitest"; import { startCli } from "../../src/cli.js"; +import { SENTRY_CLI } from "../../src/sentry-cli.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- spy on the startup dependency import * as upgrade from "../../src/lib/upgrade.js"; import { useTestConfigDir } from "../helpers.js"; @@ -22,7 +23,7 @@ test("fatal errors preserve their name while redacting credentials", async () => try { process.argv = ["node", "sentry", "--help"]; - await startCli(); + await startCli(SENTRY_CLI); expect(cleanup).toHaveBeenCalledOnce(); expect(stderr).toHaveBeenLastCalledWith( diff --git a/packages/cli/test/lib/help-graphics.test.ts b/packages/cli/test/lib/help-graphics.test.ts new file mode 100644 index 000000000..4b48dc203 --- /dev/null +++ b/packages/cli/test/lib/help-graphics.test.ts @@ -0,0 +1,57 @@ +/** + * Help's kitty/sixel image banner is registered by the full CLI (`app.ts`), + * not imported by `lib/help.ts`, so `@sentry/mcp-server` can leave the image + * code out of its bundle. These tests pin that wiring. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const IMAGE = ""; + +vi.mock("../../src/lib/sixel.js", async (importOriginal) => ({ + ...(await importOriginal()), + graphicsBanner: vi.fn(() => IMAGE), + graphicsBannerExtraRows: vi.fn(() => 2), +})); + +// Registers the route map and, with it, the image banner. +await import("../../src/app.js"); +const sixel = await import("../../src/lib/sixel.js"); +const { printCustomHelp, setGraphicsBanner } = + await import("../../src/lib/help.js"); + +/** A row of the block-art banner (see lib/banner.ts). */ +const BLOCK_ART = "███"; + +describe("help graphics banner", () => { + let savedTty = process.stdout.isTTY; + + beforeEach(() => { + savedTty = process.stdout.isTTY; + process.stdout.isTTY = true; + vi.mocked(sixel.graphicsBanner).mockClear(); + }); + + afterEach(() => { + process.stdout.isTTY = savedTty; + setGraphicsBanner({ + render: sixel.graphicsBanner, + extraRows: sixel.graphicsBannerExtraRows, + }); + }); + + test("the CLI app registers the kitty/sixel banner", () => { + const output = printCustomHelp(); + expect(sixel.graphicsBanner).toHaveBeenCalledOnce(); + expect(output).toContain(IMAGE); + expect(output).not.toContain(BLOCK_ART); + }); + + test("without a registered banner, help shows the block-art banner", () => { + setGraphicsBanner(undefined); + const output = printCustomHelp(); + expect(sixel.graphicsBanner).not.toHaveBeenCalled(); + expect(output).not.toContain(IMAGE); + expect(output).toContain(BLOCK_ART); + }); +}); diff --git a/packages/cli/test/lib/mcp-auth.test.ts b/packages/cli/test/lib/mcp-auth.test.ts index e0388a2d4..1daeac009 100644 --- a/packages/cli/test/lib/mcp-auth.test.ts +++ b/packages/cli/test/lib/mcp-auth.test.ts @@ -6,15 +6,15 @@ import { MalformedAuthTokenError, } from "../../src/lib/errors.js"; import { - prepareMcpServerArgs, - resolveCliMcpAccessToken, - startMcpServer, + resolveCliMcpCredential, + withConfiguredTarget, } from "../../src/lib/mcp.js"; import { resetHostScopingState, useEnvSandbox, useTestConfigDir, } from "../helpers.js"; +import { parseMcpFlags } from "./mcp/parse-flags.js"; useTestConfigDir("mcp-auth-"); useEnvSandbox([ @@ -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", @@ -98,57 +104,68 @@ describe("resolveCliMcpAccessToken", () => { }), ).rejects.toBeInstanceOf(MalformedAuthTokenError); }); - - test("does not expose a second MCP authentication flow", async () => { - await expect(startMcpServer(["auth", "login"])).rejects.toThrow( - "Use `sentry auth` to manage credentials for `sentry mcp`.", - ); - }); }); -describe("prepareMcpServerArgs", () => { - test("uses SENTRY_HOST before SENTRY_URL for self-hosted instances", () => { +/** Target flags `sentry mcp ` hands the server, as `--host`/`--url`. */ +async function targetFlags(args: string[], sentryUrl?: string) { + const flags = withConfiguredTarget(await parseMcpFlags(args), sentryUrl); + return { + host: flags.host, + url: flags.url, + insecureHttp: flags["insecure-http"], + }; +} + +describe("withConfiguredTarget", () => { + test("uses SENTRY_HOST before SENTRY_URL for self-hosted instances", async () => { process.env.SENTRY_HOST = "http://sentry.internal:9000"; process.env.SENTRY_URL = "https://sentry.example.com"; - expect(prepareMcpServerArgs([])).toEqual([ - "--host=sentry.internal:9000", - "--insecure-http", - ]); + expect(await targetFlags([])).toEqual({ + host: "sentry.internal:9000", + url: undefined, + insecureHttp: true, + }); }); - test("translates an insecure CLI URL into MCP host flags", () => { - expect(prepareMcpServerArgs([], "http://sentry.internal:9000")).toEqual([ - "--host=sentry.internal:9000", - "--insecure-http", - ]); + test("translates an insecure CLI URL into MCP host flags", async () => { + expect(await targetFlags([], "http://sentry.internal:9000")).toEqual({ + host: "sentry.internal:9000", + url: undefined, + insecureHttp: true, + }); }); - test("translates a secure SENTRY_HOST origin into a host flag", () => { + test("translates a secure SENTRY_HOST origin into a host flag", async () => { process.env.SENTRY_HOST = "https://sentry.example.com"; // A documented `SENTRY_HOST=https://…` export must not reach the MCP - // parser raw (it rejects origins); it becomes a plain --host instead. - expect(prepareMcpServerArgs([])).toEqual(["--host=sentry.example.com"]); + // config raw (it rejects origins); it becomes a plain --host instead. + expect(await targetFlags([])).toEqual({ + host: "sentry.example.com", + url: undefined, + insecureHttp: false, + }); }); - test("preserves an explicit MCP target over the CLI URL", () => { + test("preserves an explicit MCP target over the CLI URL", async () => { expect( - prepareMcpServerArgs( - ["--host=sentry.example.com"], - "http://localhost:9000", - ), - ).toEqual(["--host=sentry.example.com"]); + await targetFlags(["--host=sentry.example.com"], "http://localhost:9000"), + ).toEqual({ + host: "sentry.example.com", + url: undefined, + insecureHttp: false, + }); }); - test("keeps an explicit --host --insecure-http over the CLI URL", () => { + test("keeps an explicit --host --insecure-http over the CLI URL", async () => { // The CLI http URL must not sneak in an extra --host that MCP would // prefer over the user's explicit insecure target. expect( - prepareMcpServerArgs( + await targetFlags( ["--host=localhost:9000", "--insecure-http"], "http://sentry.example.com", ), - ).toEqual(["--host=localhost:9000", "--insecure-http"]); + ).toEqual({ host: "localhost:9000", url: undefined, insecureHttp: true }); }); }); diff --git a/packages/cli/test/lib/mcp-command.test.ts b/packages/cli/test/lib/mcp-command.test.ts index dbfdb6077..0c42a8a5e 100644 --- a/packages/cli/test/lib/mcp-command.test.ts +++ b/packages/cli/test/lib/mcp-command.test.ts @@ -1,17 +1,256 @@ -import { expect, test } from "vitest"; +import { ALL_SKILLS } from "@sentry/mcp-core/skills"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { routes } from "../../src/app.js"; -import { getMcpArgs } from "../../src/cli.js"; +import { type CliEntry, isMcpInvocation, runCli } from "../../src/cli.js"; +import { MCP_SKILL_NAMES } from "../../src/commands/mcp.js"; +import { ValidationError } from "../../src/lib/errors.js"; +import { routes as mcpServerRoutes } from "../../src/mcp-server-app.js"; +import { SENTRY_CLI } from "../../src/sentry-cli.js"; +import { useTestConfigDir } from "../helpers.js"; -test("exposes the local MCP server through the sentry CLI", () => { - expect( - routes.getAllEntries().some((entry) => entry.name.original === "mcp"), - ).toBe(true); +const mocks = vi.hoisted(() => ({ + serveMcp: vi.fn(), + withTelemetry: vi.fn(), + scheduleForceExit: vi.fn(), + closeGlobalDispatcher: vi.fn(), +})); + +vi.mock("../../src/lib/mcp.js", () => ({ + serveMcp: (...args: unknown[]) => mocks.serveMcp(...args), +})); +vi.mock("../../src/lib/telemetry.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + withTelemetry: (...args: Parameters) => { + mocks.withTelemetry(); + return actual.withTelemetry(...args); + }, + }; +}); +vi.mock("../../src/lib/force-exit.js", () => ({ + scheduleForceExit: () => mocks.scheduleForceExit(), +})); +vi.mock("../../src/lib/close-dispatcher.js", () => ({ + closeGlobalDispatcher: () => mocks.closeGlobalDispatcher(), +})); + +useTestConfigDir("mcp-command-"); + +const MCP_SERVER: CliEntry = { + loadApp: () => import("../../src/mcp-server-app.js"), +}; + +const originalExitCode = process.exitCode; +let stdout: string; +let stderr: string; + +beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockReset(); + } + mocks.serveMcp.mockResolvedValue(undefined); + mocks.closeGlobalDispatcher.mockResolvedValue(undefined); + stdout = ""; + stderr = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = originalExitCode; +}); + +describe("isMcpInvocation", () => { + test("finds `mcp` after leading global flags in the CLI", () => { + expect(isMcpInvocation(routes, ["mcp"])).toBe(true); + expect( + isMcpInvocation(routes, [ + "--verbose", + "mcp", + "--host=sentry.example.com", + ]), + ).toBe(true); + expect(isMcpInvocation(routes, ["--org", "acme", "mcp"])).toBe(true); + expect(isMcpInvocation(routes, ["issue", "mcp"])).toBe(false); + expect(isMcpInvocation(routes, [])).toBe(false); + }); + + test("treats a bare invocation as `mcp` where it is the default", () => { + expect(isMcpInvocation(mcpServerRoutes, [])).toBe(true); + expect( + isMcpInvocation(mcpServerRoutes, [ + "--access-token", + "tok", + "--all-skills", + ]), + ).toBe(true); + expect(isMcpInvocation(mcpServerRoutes, ["auth", "status"])).toBe(false); + expect(isMcpInvocation(mcpServerRoutes, ["help"])).toBe(false); + }); }); -test("recognizes MCP after leading global flags", () => { - expect(getMcpArgs(["--verbose", "mcp", "--host=sentry.example.com"])).toEqual( - ["--host=sentry.example.com"], - ); - expect(getMcpArgs(["--org", "acme", "mcp"])).toEqual([]); - expect(getMcpArgs(["issue", "mcp"])).toBeUndefined(); +describe("@sentry/mcp-server application", () => { + test("has only mcp, auth, and help, with mcp as the default", () => { + expect( + mcpServerRoutes.getAllEntries().map((entry) => entry.name.original), + ).toEqual(["mcp", "auth", "help"]); + expect(mcpServerRoutes.getDefaultCommand()).toBe( + mcpServerRoutes.getRoutingTargetForInput("mcp"), + ); + }); + + test("starts the server without a command", async () => { + await runCli(["--host=sentry.example.com", "--all-skills"], MCP_SERVER); + + expect(mocks.serveMcp).toHaveBeenCalledOnce(); + expect(mocks.serveMcp.mock.lastCall?.[0]).toMatchObject({ + host: "sentry.example.com", + "all-skills": true, + }); + }); +}); + +describe("running `mcp`", () => { + test("serves through Stricli, without the CLI's telemetry", async () => { + await runCli(["mcp", "--access-token=tok"], SENTRY_CLI); + + expect(mocks.serveMcp).toHaveBeenCalledOnce(); + expect(mocks.serveMcp.mock.lastCall?.[0]).toMatchObject({ + "access-token": "tok", + }); + expect(mocks.withTelemetry).not.toHaveBeenCalled(); + }); + + test("ignores --json and --fields before `mcp`", async () => { + for (const leading of [ + ["--json"], + ["--no-json"], + ["--fields", "id,title"], + ["--fields=id"], + ["--json", "--fields", "id"], + ]) { + mocks.serveMcp.mockClear(); + process.exitCode = originalExitCode; + + await runCli( + [...leading, "mcp", "--host=sentry.example.com"], + SENTRY_CLI, + ); + + expect(stderr).toBe(""); + expect(mocks.serveMcp).toHaveBeenCalledOnce(); + expect(mocks.serveMcp.mock.lastCall?.[0]).toMatchObject({ + host: "sentry.example.com", + }); + } + }); + + test("ignores --json and --fields before the default command", async () => { + await runCli(["--json", "--fields", "id", "--all-skills"], MCP_SERVER); + + expect(stderr).toBe(""); + expect(mocks.serveMcp).toHaveBeenCalledOnce(); + expect(mocks.serveMcp.mock.lastCall?.[0]).toMatchObject({ + "all-skills": true, + }); + }); + + test("rejects --json after `mcp`, where flags are the server's", async () => { + await runCli(["mcp", "--json"], SENTRY_CLI); + + expect(mocks.serveMcp).not.toHaveBeenCalled(); + expect(stderr).toContain("--json"); + expect(process.exitCode).toBeLessThan(0); + }); + + test("passes the context's working directory, which survives a deleted one", async () => { + vi.spyOn(process, "cwd").mockImplementation(() => { + throw Object.assign(new Error("uv_cwd"), { code: "ENOENT" }); + }); + const savedPwd = process.env.PWD; + process.env.PWD = "/deleted/worktree"; + try { + await runCli(["mcp", "--host=sentry.example.com"], SENTRY_CLI); + } finally { + process.env.PWD = savedPwd; + vi.mocked(process.cwd).mockRestore(); + } + + expect(mocks.serveMcp).toHaveBeenCalledOnce(); + expect(mocks.serveMcp.mock.lastCall?.[1]).toBe("/deleted/worktree"); + }); + + test("releases the network only after the server returns", async () => { + let finishServing = () => undefined as void; + mocks.serveMcp.mockReturnValue( + new Promise((resolve) => { + finishServing = resolve; + }), + ); + + const running = runCli(["mcp"], SENTRY_CLI); + await vi.waitFor(() => expect(mocks.serveMcp).toHaveBeenCalledOnce()); + // A live stdio server must not be force-exited or lose its dispatcher. + expect(mocks.scheduleForceExit).not.toHaveBeenCalled(); + expect(mocks.closeGlobalDispatcher).not.toHaveBeenCalled(); + + finishServing(); + await running; + expect(mocks.scheduleForceExit).toHaveBeenCalledOnce(); + expect(mocks.closeGlobalDispatcher).toHaveBeenCalledOnce(); + }); + + test("reports a setup failure and cleans up", async () => { + mocks.serveMcp.mockRejectedValue(new ValidationError("bad --skills")); + + await runCli(["mcp", "--skills=nope"], SENTRY_CLI); + + expect(stderr).toContain("bad --skills"); + expect(process.exitCode).toBeGreaterThan(0); + expect(mocks.closeGlobalDispatcher).toHaveBeenCalledOnce(); + }); + + test("--help prints Stricli help without serving", async () => { + await runCli(["mcp", "--help"], SENTRY_CLI); + + expect(mocks.serveMcp).not.toHaveBeenCalled(); + for (const flag of [ + "--access-token", + "--host", + "--url", + "--insecure-http", + "--skills", + "--all-skills", + "--disable-skills", + "--agent-provider", + "--mcp-url", + "--sentry-dsn", + ]) { + expect(stdout).toContain(flag); + } + expect(stdout).toContain("SENTRY_ACCESS_TOKEN"); + expect(stdout).toContain(`Available skills: ${ALL_SKILLS.join(", ")}`); + }); + + test("help lists mcp-core's skills", () => { + expect([...MCP_SKILL_NAMES]).toEqual(ALL_SKILLS); + }); + + test("rejects unknown flags", async () => { + await runCli(["mcp", "--bogus"], SENTRY_CLI); + + expect(mocks.serveMcp).not.toHaveBeenCalled(); + expect(stderr).toContain("--bogus"); + // Stricli's raw (negative) argument-error code; Node.js exits with 252. + expect(process.exitCode).toBeLessThan(0); + }); }); diff --git a/packages/cli/test/lib/mcp-run.test.ts b/packages/cli/test/lib/mcp-run.test.ts deleted file mode 100644 index aefe31ff8..000000000 --- a/packages/cli/test/lib/mcp-run.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -const startMcpServer = vi.fn(); -const scheduleForceExit = vi.fn(); -const closeGlobalDispatcher = vi.fn(() => Promise.resolve()); - -vi.mock("../../src/lib/mcp.js", () => ({ - startMcpServer: (...args: unknown[]) => startMcpServer(...args), -})); -vi.mock("../../src/lib/force-exit.js", () => ({ - scheduleForceExit: (...args: unknown[]) => scheduleForceExit(...args), -})); -vi.mock("../../src/lib/close-dispatcher.js", () => ({ - closeGlobalDispatcher: (...args: unknown[]) => closeGlobalDispatcher(...args), -})); - -const originalExitCode = process.exitCode; - -beforeEach(() => { - startMcpServer.mockReset(); - scheduleForceExit.mockReset(); - closeGlobalDispatcher.mockReset(); - closeGlobalDispatcher.mockImplementation(() => Promise.resolve()); -}); - -afterEach(() => { - process.exitCode = originalExitCode; -}); - -describe("runMcpCommand", () => { - test("does not handle non-mcp invocations", async () => { - const { runMcpCommand } = await import("../../src/cli.js"); - - await expect(runMcpCommand(["issue", "list"])).resolves.toBe(false); - expect(startMcpServer).not.toHaveBeenCalled(); - }); - - test("keeps a running server alive on success", async () => { - startMcpServer.mockResolvedValue(undefined); - const { runMcpCommand } = await import("../../src/cli.js"); - - await expect(runMcpCommand(["mcp"])).resolves.toBe(true); - - expect(startMcpServer).toHaveBeenCalledWith([]); - // A live stdio server must not be force-exited or have its dispatcher - // destroyed — those would kill the server right after it starts. - expect(scheduleForceExit).not.toHaveBeenCalled(); - expect(closeGlobalDispatcher).not.toHaveBeenCalled(); - }); - - test("cleans up network resources when setup fails", async () => { - startMcpServer.mockRejectedValue(new Error("boom")); - const { runMcpCommand } = await import("../../src/cli.js"); - - await expect(runMcpCommand(["mcp"])).resolves.toBe(true); - - // A terminal setup failure never reaches the main runCli finally, so the - // undici dispatcher and force-exit backstop must be handled here. - expect(scheduleForceExit).toHaveBeenCalledTimes(1); - expect(closeGlobalDispatcher).toHaveBeenCalledTimes(1); - expect(process.exitCode).toBeGreaterThan(0); - }); -}); diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 79af97885..9ce93d02c 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -1,28 +1,72 @@ +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"; +import { parseMcpFlags } from "./mcp/parse-flags.js"; -type CapturedOptions = { - resolveAccessToken?: (config: unknown) => Promise; - onUpstreamUnauthorized?: ( - setAccessToken: (token: string) => void, - ) => void | Promise; -}; +type Served = { config: McpServerConfig; options: McpServeOptions }; -const captured: { options?: CapturedOptions } = {}; +const served: { calls: Served[] } = { calls: [] }; -vi.mock("@sentry/mcp-server", () => ({ - runMcpServer: vi.fn(async (_args: string[], options: CapturedOptions) => { - captured.options = options; - }), +/** + * 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("../../src/lib/mcp/server.js", async (importOriginal) => ({ + ...(await importOriginal()), + serveMcpStdio: vi.fn( + async (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", @@ -30,71 +74,501 @@ useEnvSandbox([ "SENTRY_URL", ]); +/** The command context's working directory that {@link serve} passes. */ +let workingDir = process.cwd(); + beforeEach(async () => { - captured.options = undefined; + served.calls = []; + workingDir = process.cwd(); await resetHostScopingState(); }); afterEach(async () => { + vi.restoreAllMocks(); await resetHostScopingState(); }); -describe("startMcpServer credential wiring", () => { - test("resolves the CLI session token for the MCP server", async () => { +/** Run `sentry mcp ` up to the (mocked) stdio server. */ +async function serve(args: string[]): Promise { + const { serveMcp } = await import("../../src/lib/mcp.js"); + await serveMcp(await parseMcpFlags(args), workingDir); +} + +async function start(args: string[]): Promise { + await serve(args); + expect(served.calls).toHaveLength(1); + const [call] = served.calls; + if (!call) { + throw new Error("serveMcpStdio was not called"); + } + return call; +} + +describe("serveMcp credential wiring", () => { + 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 { 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"); + }); +}); - const { startMcpServer } = await import("../../src/lib/mcp.js"); - await startMcpServer([]); +describe("serveMcp account pinning", () => { + const originalFetch = globalThis.fetch; + const refreshedWith: string[] = []; - // The 401 handler needs the resolved target; prime it via resolveAccessToken. - await captured.options?.resolveAccessToken?.({ - sentryHost: "sentry.io", - sentryProtocol: "https", + /** 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; + } - // Simulate a refreshed credential landing in the store before the handler runs. - setAuthToken("rotated-token", undefined, undefined, { + 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("serveMcp deprecated --access-token", () => { + const output = { stderr: [] as string[], stdout: [] as string[] }; + + beforeEach(async () => { + const { resetDeprecationHintForTesting } = + await import("../../src/lib/mcp.js"); + resetDeprecationHintForTesting(); + output.stderr = []; + output.stdout = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + output.stderr.push(String(chunk)); + return true; + }); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.stdout.push(String(chunk)); + return true; + }); + }); + + test("serves --access-token as a forced SENTRY_AUTH_TOKEN", async () => { + await clearAuth(); + + const { config, options } = await start(["--access-token=static-token"]); + expect(options.accessToken).toBe("static-token"); + expect(config.sentryHost).toBe("sentry.io"); + // The env-token path: a refresh re-reads the same env token. + await expect(options.refreshAccessToken?.()).resolves.toBe("static-token"); + // Mapped in an env copy; the process environment is left alone. + expect(process.env.SENTRY_AUTH_TOKEN).toBeUndefined(); + expect(process.env.SENTRY_FORCE_ENV_TOKEN).toBeUndefined(); + }); + + test("serves the same as SENTRY_AUTH_TOKEN with SENTRY_FORCE_ENV_TOKEN=1", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.io", + }); + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + const fromEnv = await start([]); + const refreshedFromEnv = await fromEnv.options.refreshAccessToken?.(); + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_FORCE_ENV_TOKEN; + + served.calls = []; + const fromFlag = await start(["--access-token=env-token"]); + expect(fromFlag.options.accessToken).toBe(fromEnv.options.accessToken); + expect(refreshedFromEnv).toBe("env-token"); + await expect(fromFlag.options.refreshAccessToken?.()).resolves.toBe( + refreshedFromEnv, + ); + }); + + 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"); + }); + + 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("overrides a stored login, before and after startup", async () => { + setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", }); - let written: string | undefined; - await captured.options?.onUpstreamUnauthorized?.((token) => { - written = token; + const { options } = await start(["--access-token=static-token"]); + expect(options.accessToken).toBe("static-token"); + + setAuthToken("other-session-token", undefined, undefined, { + host: "https://sentry.io", }); + await expect(options.refreshAccessToken?.()).resolves.toBe("static-token"); + }); - expect(written).toBe("rotated-token"); + test("overrides SENTRY_AUTH_TOKEN and SENTRY_TOKEN", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-auth-token"; + process.env.SENTRY_TOKEN = "env-legacy-token"; + + const { options } = await start(["--access-token=flag-token"]); + expect(options.accessToken).toBe("flag-token"); + await expect(options.refreshAccessToken?.()).resolves.toBe("flag-token"); + }); + + test("prints a one-time deprecation hint to stderr, never stdout or the token", async () => { + await start(["--access-token=sntryu_secret-value"]); + served.calls = []; + await start(["--access-token=sntryu_secret-value"]); + + expect(output.stdout).toEqual([]); + expect(output.stderr).toEqual([ + "Deprecation warning: --access-token is deprecated; set SENTRY_AUTH_TOKEN " + + "(and SENTRY_FORCE_ENV_TOKEN=1 to override a stored login) instead.\n", + ]); + expect(output.stderr.join("")).not.toContain("secret-value"); }); - test("ignores an upstream 401 before any token has been resolved", async () => { + test("names SENTRY_ACCESS_TOKEN in its hint when that is the source", async () => { + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + await start([]); + + expect(output.stdout).toEqual([]); + expect(output.stderr.join("")).toContain( + "SENTRY_ACCESS_TOKEN is deprecated; set SENTRY_AUTH_TOKEN", + ); + expect(output.stderr.join("")).not.toContain("env-static-token"); + }); + + test("prints no hint without a deprecated token", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", }); + await start([]); + expect(output.stderr).toEqual([]); + }); + + test("keeps the token out of errors", async () => { + const malformed = "sntryu_secret has a space"; + const malformedError = await serve([`--access-token=${malformed}`]).catch( + (error: unknown) => error, + ); + expect(malformedError).toBeInstanceOf(MalformedAuthTokenError); + expect(String(malformedError)).not.toContain("secret"); + + const orgToken = mintSntrysToken({ + iat: 1, + url: "https://sentry.example.com", + org: "acme", + }); + const hostError = await serve([ + `--access-token=${orgToken}`, + "--host=evil.example.com", + ]).catch((error: unknown) => error); + expect(hostError).toBeInstanceOf(HostScopeError); + expect(String(hostError)).not.toContain(orgToken); + expect(output.stderr.join("")).not.toContain(orgToken); + expect(served.calls).toHaveLength(0); + }); + + 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 () => { + await expect(serve(["--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", + }); + + await expect( + serve([`--access-token=${token}`, "--host=evil.example.com"]), + ).rejects.toBeInstanceOf(HostScopeError); + expect(served.calls).toHaveLength(0); + }); +}); + +describe("serveMcp .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); + workingDir = dir; + clearSentryCliRcCache(); + await applySentryCliRcEnvShim(dir); + } + + const rcDirs: string[] = []; + + beforeEach(async () => { + await clearAuth(); + }); + + 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"; + + await expect(serve([])).rejects.toBeInstanceOf(HostScopeError); + await expect(serve(["--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 an org token whose claim matches a self-hosted rc URL", async () => { + await inRcDir("https://sentry.example.com"); + const token = mintSntrysToken({ + iat: 1, + url: "https://sentry.example.com", + org: "acme", + }); + + const fromFlag = await start([`--access-token=${token}`]); + expect(fromFlag.options.accessToken).toBe(token); - const { startMcpServer } = await import("../../src/lib/mcp.js"); - await startMcpServer([]); + served.calls = []; + process.env.SENTRY_ACCESS_TOKEN = token; + const fromEnv = await start([]); + expect(fromEnv.options.accessToken).toBe(token); + }); - let written: string | undefined; - await captured.options?.onUpstreamUnauthorized?.((token) => { - written = token; + test("refuses an org token whose claim does not match the rc URL", async () => { + await inRcDir("https://evil.example.com"); + const token = mintSntrysToken({ + iat: 1, + url: "https://sentry.example.com", + org: "acme", + }); + + await expect(serve([`--access-token=${token}`])).rejects.toBeInstanceOf( + HostScopeError, + ); + expect(served.calls).toHaveLength(0); + }); + + test("starts when the working directory has been deleted", async () => { + // `process.cwd()` throws ENOENT once the directory is removed; the command + // context falls back to `PWD`, which names a directory that is gone. + vi.spyOn(process, "cwd").mockImplementation(() => { + throw Object.assign(new Error("uv_cwd"), { code: "ENOENT" }); + }); + workingDir = join(tmpdir(), "mcp-start-deleted-worktree"); + + 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"); }); }); 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..bcdf168b6 --- /dev/null +++ b/packages/cli/test/lib/mcp/args.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; +import { + mergeMcpArgs, + parseMcpEnv, + splitInlineFlagValues, +} from "../../../src/lib/mcp/args.js"; +import { parseMcpFlags } from "./parse-flags.js"; + +describe("sentry mcp flags", () => { + it("parses known flags", async () => { + const parsed = await parseMcpFlags([ + "--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", + ]); + expect(parsed).toMatchObject({ + "access-token": "tok", + host: "sentry.io", + "insecure-http": true, + 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": true, + }); + }); + + it("defaults boolean flags to off", async () => { + expect(await parseMcpFlags([])).toMatchObject({ + "insecure-http": false, + "all-skills": false, + experimental: false, + }); + }); + + it("parses --disable-skills", async () => { + const parsed = await parseMcpFlags(["--disable-skills=seer"]); + expect(parsed["disable-skills"]).toBe("seer"); + }); + + it.each(["azure-openai", "openrouter"])( + "parses --agent-provider=%s", + async (provider) => { + const parsed = await parseMcpFlags([`--agent-provider=${provider}`]); + expect(parsed["agent-provider"]).toBe(provider); + }, + ); + + it("accepts empty and multi-line --flag=value forms", async () => { + const flags = await parseMcpFlags(["--sentry-dsn=", "--access-token=a\nb"]); + expect(flags["sentry-dsn"]).toBe(""); + expect(flags["access-token"]).toBe("a\nb"); + }); + + it("rejects unknown flags and positionals", async () => { + await expect(parseMcpFlags(["--unknown"])).rejects.toThrow(/unknown/); + await expect(parseMcpFlags(["extra"])).rejects.toThrow(/extra/); + }); +}); + +describe("splitInlineFlagValues", () => { + it("splits only the joined forms Stricli does not read", () => { + expect( + splitInlineFlagValues([ + "--sentry-dsn=", + "--access-token=tok\n", + "--host=sentry.io", + "--all-skills", + "x=", + ]), + ).toEqual([ + "--sentry-dsn", + "", + "--access-token", + "tok\n", + "--host=sentry.io", + "--all-skills", + "x=", + ]); + }); +}); + +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.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: flags over env", async () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_URL: "envmcp", + SENTRY_DSN: "envdsn", + }); + const flags = await parseMcpFlags([ + "--access-token=clitok", + "--host=clihost", + "--insecure-http", + "--mcp-url=climcp", + "--sentry-dsn=clidsn", + "--openai-base-url=https://api.cli/v1", + ]); + const merged = mergeMcpArgs(flags, env); + 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: flags over env", async () => { + const env = parseMcpEnv({ MCP_SKILLS: "inspect" }); + const flags = await parseMcpFlags(["--skills=inspect,triage"]); + expect(mergeMcpArgs(flags, env).skills).toBe("inspect,triage"); + }); + + it("falls back to env when the skills flag is absent", async () => { + const env = parseMcpEnv({ MCP_SKILLS: "inspect,triage" }); + expect(mergeMcpArgs(await parseMcpFlags([]), env).skills).toBe( + "inspect,triage", + ); + }); + + it("lets --all-skills override env skills", async () => { + const env = parseMcpEnv({ MCP_SKILLS: "inspect" }); + const merged = mergeMcpArgs(await parseMcpFlags(["--all-skills"]), env); + expect(merged.allSkills).toBe(true); + expect(merged.skills).toBeUndefined(); + }); + + it("applies precedence for disableSkills: flags over env", async () => { + const env = parseMcpEnv({ MCP_DISABLE_SKILLS: "docs" }); + const flags = await parseMcpFlags(["--disable-skills=seer"]); + expect(mergeMcpArgs(flags, env).disableSkills).toBe("seer"); + }); + + it("falls back to env when the disable-skills flag is absent", async () => { + const env = parseMcpEnv({ MCP_DISABLE_SKILLS: "seer" }); + expect(mergeMcpArgs(await parseMcpFlags([]), env).disableSkills).toBe( + "seer", + ); + }); + + it("applies precedence for agentProvider: flags over env", async () => { + const env = parseMcpEnv({ EMBEDDED_AGENT_PROVIDER: "openai" }); + const flags = await parseMcpFlags(["--agent-provider=azure-openai"]); + expect(mergeMcpArgs(flags, env).agentProvider).toBe("azure-openai"); + }); +}); + +describe("@sentry/mcp-server flag parity", () => { + it("accepts every flag the server has always taken", async () => { + const flags = await parseMcpFlags([ + "--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", + ]); + expect(mergeMcpArgs(flags, {})).toEqual({ + 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", + agentProviderFromFlag: true, + organizationSlug: "acme", + projectSlug: "web", + skills: "inspect", + allSkills: true, + disableSkills: "seer", + experimental: true, + }); + }); + + it("accepts space-separated flag values", async () => { + const flags = await parseMcpFlags([ + "--access-token", + "tok", + "--host", + "h.test", + ]); + expect(flags["access-token"]).toBe("tok"); + expect(flags.host).toBe("h.test"); + }); + + it("reads every 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({ + 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("leaves the deprecated SENTRY_ACCESS_TOKEN to serveMcp", () => { + expect(parseMcpEnv({ SENTRY_ACCESS_TOKEN: "envtok" })).toEqual({}); + }); + + it("records whether the agent provider came from the flag", async () => { + const env = parseMcpEnv({ EMBEDDED_AGENT_PROVIDER: "openai" }); + expect( + mergeMcpArgs(await parseMcpFlags([]), env).agentProviderFromFlag, + ).toBe(false); + expect( + mergeMcpArgs(await parseMcpFlags(["--agent-provider=openai"]), env) + .agentProviderFromFlag, + ).toBe(true); + }); +}); diff --git a/packages/mcp-server/src/cli/resolve.test.ts b/packages/cli/test/lib/mcp/config.test.ts similarity index 77% rename from packages/mcp-server/src/cli/resolve.test.ts rename to packages/cli/test/lib/mcp/config.test.ts index d3fe56251..b75be9248 100644 --- a/packages/mcp-server/src/cli/resolve.test.ts +++ b/packages/cli/test/lib/mcp/config.test.ts @@ -1,32 +1,19 @@ -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"); - }); - - it("uses provided clientId over default", () => { - const cfg = finalize({ - accessToken: "tok", - clientId: "custom-client-id", - unknownArgs: [], - }); - expect(cfg.clientId).toBe("custom-client-id"); +function finalize(input: Partial) { + return finalizeMcpConfig({ + agentProviderFromFlag: false, + ...input, }); +} +describe("finalizeMcpConfig", () => { it("normalizes host from URL", () => { const cfg = finalize({ - accessToken: "tok", url: "https://sentry.example.com", - unknownArgs: [], }); expect(cfg.sentryHost).toBe("sentry.example.com"); expect(cfg.sentryProtocol).toBe("https"); @@ -34,10 +21,8 @@ describe("cli/finalize", () => { it("uses http protocol for self-hosted hosts when --insecure-http is enabled", () => { const cfg = finalize({ - accessToken: "tok", host: "sentry.internal:9000", insecureHttp: true, - unknownArgs: [], }); expect(cfg.sentryHost).toBe("sentry.internal:9000"); expect(cfg.sentryProtocol).toBe("http"); @@ -45,9 +30,7 @@ describe("cli/finalize", () => { it("accepts valid OpenAI base URL", () => { const cfg = finalize({ - accessToken: "tok", openaiBaseUrl: "https://api.proxy.example/v1", - unknownArgs: [], }); expect(cfg.openaiBaseUrl).toBe( new URL("https://api.proxy.example/v1").toString(), @@ -57,27 +40,21 @@ describe("cli/finalize", () => { it("rejects invalid OpenAI base URL", () => { expect(() => finalize({ - accessToken: "tok", openaiBaseUrl: "ftp://example.com", - unknownArgs: [], }), ).toThrow(/OPENAI base URL must use http or https scheme/); }); it("accepts azure-openai as a valid explicit provider", () => { const cfg = finalize({ - accessToken: "tok", agentProvider: "azure-openai", - unknownArgs: [], }); expect(cfg.agentProvider).toBe("azure-openai"); }); it("accepts openrouter as a valid explicit provider", () => { const cfg = finalize({ - accessToken: "tok", agentProvider: "openrouter", - unknownArgs: [], }); expect(cfg.agentProvider).toBe("openrouter"); }); @@ -85,28 +62,24 @@ describe("cli/finalize", () => { it("rejects invalid explicit provider values", () => { expect(() => finalize({ - accessToken: "tok", agentProvider: "bad-provider", - unknownArgs: [], }), ).toThrow(/Must be "openai", "azure-openai", "anthropic", or "openrouter"/); }); it("throws on non-https URL", () => { - expect(() => - finalize({ accessToken: "tok", url: "http://bad", unknownArgs: [] }), - ).toThrow(/must be a full HTTPS URL/); + expect(() => finalize({ url: "http://bad" })).toThrow( + /must be a full HTTPS URL/, + ); }); it("throws when --insecure-http is used with --url", () => { expect(() => finalize({ - accessToken: "tok", url: "https://sentry.example.com", 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", () => { @@ -115,12 +88,10 @@ describe("cli/finalize", () => { // "must be a full HTTPS URL" error. expect(() => finalize({ - accessToken: "tok", url: "http://sentry.internal:9000", 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"])( @@ -128,10 +99,8 @@ describe("cli/finalize", () => { (host) => { expect(() => finalize({ - accessToken: "tok", host, insecureHttp: true, - unknownArgs: [], }), ).toThrow(/only supported for self-hosted Sentry hosts/); }, @@ -141,9 +110,7 @@ describe("cli/finalize", () => { it("throws on invalid skills", () => { expect(() => finalize({ - accessToken: "tok", skills: "invalid-skill", - unknownArgs: [], }), ).toThrow(/Invalid skills provided: invalid-skill/); }); @@ -151,18 +118,14 @@ describe("cli/finalize", () => { it("validates multiple skills and reports all invalid ones", () => { expect(() => finalize({ - accessToken: "tok", skills: "inspect,invalid1,triage,invalid2", - unknownArgs: [], }), ).toThrow(/Invalid skills provided: invalid1, invalid2/); }); it("resolves valid skills in override mode (--skills)", () => { const cfg = finalize({ - accessToken: "tok", skills: "inspect,triage", - unknownArgs: [], }); expect(cfg.finalSkills.has("inspect")).toBe(true); expect(cfg.finalSkills.has("triage")).toBe(true); @@ -173,9 +136,7 @@ describe("cli/finalize", () => { it("allows explicit legacy docs skill grants", () => { const cfg = finalize({ - accessToken: "tok", skills: "docs", - unknownArgs: [], }); expect(cfg.finalSkills).toEqual(new Set(["docs"])); }); @@ -183,9 +144,7 @@ describe("cli/finalize", () => { it("throws on legacy preprod skill in stdio", () => { expect(() => finalize({ - accessToken: "tok", skills: "preprod", - unknownArgs: [], }), ).toThrow(/Invalid skills provided: preprod/); }); @@ -193,18 +152,13 @@ describe("cli/finalize", () => { it("throws on empty skills after validation", () => { expect(() => finalize({ - accessToken: "tok", skills: "invalid1,invalid2", - unknownArgs: [], }), ).toThrow(/Invalid skills provided/); }); it("grants all active skills when no skills specified", () => { - const cfg = finalize({ - accessToken: "tok", - unknownArgs: [], - }); + const cfg = finalize({}); expect(cfg.finalSkills.size).toBe(4); expect(cfg.finalSkills.has("inspect")).toBe(true); expect(cfg.finalSkills.has("triage")).toBe(true); @@ -216,9 +170,7 @@ describe("cli/finalize", () => { it("grants all active skills with --all-skills", () => { const cfg = finalize({ - accessToken: "tok", allSkills: true, - unknownArgs: [], }); expect(cfg.finalSkills.size).toBe(4); expect(cfg.finalSkills.has("inspect")).toBe(true); @@ -232,18 +184,14 @@ describe("cli/finalize", () => { // Self-hosted defaults it("keeps seer in the default set for regional sentry.io hosts", () => { const cfg = finalize({ - accessToken: "tok", host: "us.sentry.io", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(true); }); it("excludes seer from the default set on self-hosted hosts", () => { const cfg = finalize({ - accessToken: "tok", host: "sentry.example.com", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.has("inspect")).toBe(true); @@ -254,9 +202,7 @@ describe("cli/finalize", () => { it("excludes seer from the default set when self-hosted host comes from --url", () => { const cfg = finalize({ - accessToken: "tok", url: "https://sentry.example.com", - unknownArgs: [], }); expect(cfg.sentryHost).toBe("sentry.example.com"); expect(cfg.finalSkills.has("seer")).toBe(false); @@ -264,20 +210,16 @@ describe("cli/finalize", () => { it("grants seer on self-hosted hosts when requested with --skills", () => { const cfg = finalize({ - accessToken: "tok", host: "sentry.example.com", skills: "inspect,seer", - unknownArgs: [], }); expect(cfg.finalSkills).toEqual(new Set(["inspect", "seer"])); }); it("grants seer on self-hosted hosts with --all-skills", () => { const cfg = finalize({ - accessToken: "tok", host: "sentry.example.com", allSkills: true, - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(true); expect(cfg.finalSkills.size).toBe(4); @@ -285,10 +227,8 @@ describe("cli/finalize", () => { it("does not fail when --disable-skills=seer is used on a self-hosted host", () => { const cfg = finalize({ - accessToken: "tok", host: "sentry.example.com", disableSkills: "seer", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.size).toBe(3); @@ -297,10 +237,8 @@ describe("cli/finalize", () => { it("rejects combining --all-skills with --skills", () => { expect(() => finalize({ - accessToken: "tok", allSkills: true, skills: "inspect", - unknownArgs: [], }), ).toThrow(/--all-skills cannot be combined with --skills/); }); @@ -308,9 +246,7 @@ describe("cli/finalize", () => { // --disable-skills tests it("removes disabled skills from default active-skills set", () => { const cfg = finalize({ - accessToken: "tok", disableSkills: "seer", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.size).toBe(3); @@ -323,10 +259,8 @@ describe("cli/finalize", () => { it("removes disabled skills when combined with --skills", () => { const cfg = finalize({ - accessToken: "tok", skills: "inspect,triage,seer", disableSkills: "seer", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.size).toBe(2); @@ -337,9 +271,7 @@ describe("cli/finalize", () => { it("throws on invalid skill names in --disable-skills", () => { expect(() => finalize({ - accessToken: "tok", disableSkills: "invalid-skill", - unknownArgs: [], }), ).toThrow(/--disable-skills provided: invalid-skill/); }); @@ -347,19 +279,15 @@ describe("cli/finalize", () => { it("throws when all skills would be disabled", () => { expect(() => finalize({ - accessToken: "tok", skills: "seer", disableSkills: "seer", - unknownArgs: [], }), ).toThrow(/All skills have been disabled/); }); it("supports multiple comma-separated disabled skills", () => { const cfg = finalize({ - accessToken: "tok", disableSkills: "seer,docs", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.has("docs")).toBe(false); @@ -368,13 +296,43 @@ describe("cli/finalize", () => { it("silently ignores disabling a skill not in the active set", () => { const cfg = finalize({ - accessToken: "tok", skills: "inspect,triage", disableSkills: "seer", - unknownArgs: [], }); expect(cfg.finalSkills.size).toBe(2); expect(cfg.finalSkills.has("inspect")).toBe(true); expect(cfg.finalSkills.has("triage")).toBe(true); }); + + 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({ + 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({ + 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/parse-flags.ts b/packages/cli/test/lib/mcp/parse-flags.ts new file mode 100644 index 000000000..fd6f944ff --- /dev/null +++ b/packages/cli/test/lib/mcp/parse-flags.ts @@ -0,0 +1,57 @@ +/** + * Parse `sentry mcp` arguments with the command's own Stricli parameters and + * the runner's argument fix-ups, without starting the server. + */ + +// oxlint-disable no-restricted-imports -- a bare command that only captures `mcpCommand`'s parsed flags; the CLI wrapper would run its telemetry and output pipeline. +import { + buildApplication, + buildCommand, + type CommandContext, + run, +} from "@stricli/core"; +// oxlint-enable no-restricted-imports +import { mcpCommand } from "../../../src/commands/mcp.js"; +import { + type McpFlags, + splitInlineFlagValues, +} from "../../../src/lib/mcp/args.js"; + +type McpParameters = Parameters< + typeof buildCommand +>[0]["parameters"]; + +/** The flags `sentry mcp ` hands to the server. Rejects what Stricli rejects. */ +export async function parseMcpFlags( + args: readonly string[], +): Promise { + let parsed: McpFlags | undefined; + let stderr = ""; + const command = buildCommand({ + docs: { brief: "Parse sentry mcp flags" }, + parameters: (mcpCommand as unknown as { parameters: McpParameters }) + .parameters, + func(flags) { + parsed = flags; + }, + }); + await run( + buildApplication(command, { name: "sentry-mcp-test" }), + splitInlineFlagValues(args), + { + process: { + stdout: { write: () => true }, + stderr: { + write: (text: string) => { + stderr += text; + return true; + }, + }, + }, + }, + ); + if (!parsed) { + throw new Error(`rejected ${JSON.stringify(args)}: ${stderr}`); + } + return parsed; +} 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/telemetry.test.ts b/packages/cli/test/lib/mcp/telemetry.test.ts new file mode 100644 index 000000000..f53c29c0d --- /dev/null +++ b/packages/cli/test/lib/mcp/telemetry.test.ts @@ -0,0 +1,122 @@ +import type { NodeOptions } from "@sentry/node"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { CLI_VERSION, SENTRY_CLI_DSN } from "../../../src/lib/constants.js"; +import type { McpServerConfig } from "../../../src/lib/mcp/config.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +const sentry = vi.hoisted(() => ({ + init: vi.fn((_options: NodeOptions) => undefined), +})); + +vi.mock("@sentry/node", async (importOriginal) => ({ + ...(await importOriginal()), + init: sentry.init, +})); + +useTestConfigDir("mcp-telemetry-"); +useEnvSandbox(["SENTRY_CLI_NO_TELEMETRY", "DO_NOT_TRACK", "SENTRY_RELEASE"]); + +const config: McpServerConfig = { + sentryHost: "sentry.io", + sentryProtocol: "https", + agentProviderFromFlag: false, + experimental: false, + finalSkills: new Set(), +}; + +/** The options the server passed to `Sentry.init`. */ +async function initOptions( + overrides: Partial = {}, +): Promise { + const { initMcpTelemetry } = await import("../../../src/lib/mcp/server.js"); + initMcpTelemetry({ ...config, ...overrides }); + const options = sentry.init.mock.lastCall?.[0]; + if (!options) { + throw new Error("Sentry.init was not called"); + } + return options; +} + +beforeEach(() => { + sentry.init.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe("initMcpTelemetry", () => { + test("reports to the CLI's project when no DSN is configured", async () => { + const options = await initOptions(); + expect(options.dsn).toBe(SENTRY_CLI_DSN); + expect(options.enabled).toBe(true); + expect(options.initialScope).toMatchObject({ + tags: { + "app.server.distribution": "sentry mcp", + "app.server.version": CLI_VERSION, + }, + }); + expect(options.release).toBe(CLI_VERSION); + }); + + test("honors the CLI's telemetry opt-out", async () => { + process.env.SENTRY_CLI_NO_TELEMETRY = "1"; + const options = await initOptions(); + expect(options.dsn).toBe(SENTRY_CLI_DSN); + expect(options.enabled).toBe(false); + }); + + test("an explicit DSN wins and ignores the CLI opt-out", async () => { + process.env.SENTRY_CLI_NO_TELEMETRY = "1"; + const dsn = "https://key@o1.ingest.sentry.io/1"; + const options = await initOptions({ sentryDsn: dsn }); + expect(options.dsn).toBe(dsn); + expect(options.enabled).toBe(true); + }); + + test("ignores SENTRY_RELEASE so the reported release stays ours", async () => { + process.env.SENTRY_RELEASE = "spoofed"; + const options = await initOptions({ + sentryDsn: "https://key@o1.ingest.sentry.io/1", + }); + expect(options.release).toBe(CLI_VERSION); + }); + + test("tags @sentry/mcp-server installs", async () => { + const { setAppIdentity } = await import("../../../src/lib/app-identity.js"); + setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); + const options = await initOptions(); + expect(options.initialScope).toMatchObject({ + tags: { + "app.server.distribution": "@sentry/mcp-server", + "app.server.version": CLI_VERSION, + }, + }); + expect(options.release).toBe(CLI_VERSION); + }); + + test("starts without telemetry when the working directory is gone", async () => { + const { logger } = await import("../../../src/lib/logger.js"); + const { initMcpTelemetry } = await import("../../../src/lib/mcp/server.js"); + const cwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: no such file or directory, uv_cwd"); + }); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + const level = logger.level; + logger.level = 4; + try { + expect(() => initMcpTelemetry(config)).not.toThrow(); + expect(sentry.init).not.toHaveBeenCalled(); + expect(stderr.mock.calls.join("\n")).toContain( + "Working directory is unavailable, starting without telemetry: ENOENT: no such file or directory, uv_cwd", + ); + } finally { + logger.level = level; + stderr.mockRestore(); + cwd.mockRestore(); + } + }); +}); diff --git a/packages/cli/test/lib/project-context.test.ts b/packages/cli/test/lib/project-context.test.ts new file mode 100644 index 000000000..8665f31a7 --- /dev/null +++ b/packages/cli/test/lib/project-context.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test, vi } from "vitest"; +import { clearSentryCliRcCache } from "../../src/lib/sentryclirc.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +vi.mock("../../src/lib/db/project-root-cache.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../src/lib/db/project-root-cache.js") + >()), + setCachedProjectRoot: vi.fn(async () => { + throw new Error("database is locked"); + }), +})); + +useTestConfigDir("project-context-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +const dirs: string[] = []; + +afterEach(async () => { + clearSentryCliRcCache(); + await resetHostScopingState(); + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("applies the .sentryclirc env shim when the project-root cache write fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "project-context-rc-")); + dirs.push(dir); + writeFileSync( + join(dir, ".sentryclirc"), + "[defaults]\nurl = https://sentry.example.com\n[auth]\ntoken = rc-token\n", + ); + clearSentryCliRcCache(); + + const { preloadProjectContext } = + await import("../../src/lib/project-context.js"); + await preloadProjectContext(dir); + + expect(process.env.SENTRY_AUTH_TOKEN).toBe("rc-token"); + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); +}); 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/cli/test/lib/telemetry.test.ts b/packages/cli/test/lib/telemetry.test.ts index 678af2977..f37aa558c 100644 --- a/packages/cli/test/lib/telemetry.test.ts +++ b/packages/cli/test/lib/telemetry.test.ts @@ -800,6 +800,12 @@ describe("setFlagContext", () => { expect(setTagSpy).toHaveBeenCalledWith("flag.token", "[REDACTED]"); }); + test("redacts the deprecated MCP --access-token value", () => { + setFlagContext({ "access-token": "sntryu_secret" }); + expect(setTagSpy).toHaveBeenCalledWith("flag.access-token", "[REDACTED]"); + expect(JSON.stringify(setTagSpy.mock.calls)).not.toContain("sntryu_secret"); + }); + test("still sets the tag when token flag is present (presence is useful signal)", () => { setFlagContext({ token: "any-token-value" }); // The tag is set (so we know --token was used), but the value is redacted diff --git a/packages/cli/test/script/bump-version.test.ts b/packages/cli/test/script/bump-version.test.ts index 96a703a0b..caef41d36 100644 --- a/packages/cli/test/script/bump-version.test.ts +++ b/packages/cli/test/script/bump-version.test.ts @@ -16,6 +16,7 @@ const script = resolve( dirname(fileURLToPath(import.meta.url)), "../../script/bump-version.ts", ); +const skill = "plugins/sentry-cli/skills/sentry-cli"; const fixtures: string[] = []; afterEach(() => { @@ -23,31 +24,45 @@ afterEach(() => { rmSync(fixture, { recursive: true, force: true }); }); -test("post-release bump updates tracked skill versions before committing", () => { - const cwd = mkdtempSync(join(tmpdir(), "cli-bump-version-")); - fixtures.push(cwd); - const bin = join(cwd, "bin"); - const skill = "plugins/sentry-cli/skills/sentry-cli"; +/** + * Lay out `cli/` and its sibling `mcp-server/` like `packages/`, with fake + * `npm` and `git` binaries. The fake `npm version` writes the requested + * version, or the next dev version for `preminor`. + */ +function createFixture(): { root: string; cli: string; bin: string } { + const root = mkdtempSync(join(tmpdir(), "cli-bump-version-")); + fixtures.push(root); + const cli = join(root, "cli"); + const bin = join(root, "bin"); mkdirSync(bin); - mkdirSync(join(cwd, skill, "references"), { recursive: true }); - mkdirSync(join(cwd, "plugins/sentry-cli/.claude-plugin"), { + mkdirSync(join(cli, skill, "references"), { recursive: true }); + mkdirSync(join(cli, "plugins/sentry-cli/.claude-plugin"), { recursive: true, }); - writeFileSync(join(cwd, "package.json"), '{"version":"0.47.0"}\n'); + mkdirSync(join(root, "mcp-server")); + writeFileSync(join(cli, "package.json"), '{"version":"0.47.0"}\n'); + writeFileSync( + join(root, "mcp-server/package.json"), + '{"version":"0.42.0"}\n', + ); writeFileSync( - join(cwd, "plugins/sentry-cli/.claude-plugin/plugin.json"), + join(cli, "plugins/sentry-cli/.claude-plugin/plugin.json"), '{"version":"0.47.0"}\n', ); for (const path of ["SKILL.md", "references/auth.md"]) { writeFileSync( - join(cwd, skill, path), + join(cli, skill, path), "---\nname: sentry-cli\nversion: 0.47.0-dev.0\n---\n\nContent\n", ); } writeFileSync( join(bin, "npm"), - '#!/usr/bin/env node\nrequire("node:fs").writeFileSync("package.json", JSON.stringify({ version: "0.48.0-dev.0" }) + "\\n");\n', + `#!/usr/bin/env node +const args = process.argv.slice(2); +const version = args.includes("preminor") ? "0.48.0-dev.0" : args.at(-1); +require("node:fs").writeFileSync("package.json", JSON.stringify({ version }) + "\\n"); +`, ); writeFileSync( join(bin, "git"), @@ -65,29 +80,63 @@ fs.appendFileSync("git-commands", process.argv[2] + "\\n"); ); chmodSync(join(bin, "npm"), 0o755); chmodSync(join(bin, "git"), 0o755); + return { root, cli, bin }; +} - const result = spawnSync( +function runScript( + { cli, bin }: { cli: string; bin: string }, + mode: "--pre" | "--post", + env: Record = {}, +) { + return spawnSync( process.execPath, - ["--experimental-strip-types", script, "--post"], + ["--experimental-strip-types", script, mode], { - cwd, + cwd: cli, encoding: "utf8", env: { - HOME: cwd, + HOME: cli, PATH: `${bin}:${process.env.PATH ?? ""}`, - XDG_CONFIG_HOME: cwd, + XDG_CONFIG_HOME: cli, + ...env, }, timeout: 15_000, }, ); +} + +function readVersion(path: string): string { + return (JSON.parse(readFileSync(path, "utf8")) as { version: string }) + .version; +} + +test("pre-release bump gives @sentry/mcp-server the release version", () => { + const fixture = createFixture(); + + const result = runScript(fixture, "--pre", { CRAFT_NEW_VERSION: "0.48.0" }); + + expect(result.status, result.stderr).toBe(0); + expect(readVersion(join(fixture.cli, "package.json"))).toBe("0.48.0"); + expect(readVersion(join(fixture.root, "mcp-server/package.json"))).toBe( + "0.48.0", + ); +}); + +test("post-release bump updates tracked skill versions before committing", () => { + const fixture = createFixture(); + + const result = runScript(fixture, "--post"); expect(result.status, result.stderr).toBe(0); for (const path of ["SKILL.md", "references/auth.md"]) { - expect(readFileSync(join(cwd, skill, path), "utf8")).toContain( + expect(readFileSync(join(fixture.cli, skill, path), "utf8")).toContain( "version: 0.48.0-dev.0", ); } - expect(readFileSync(join(cwd, "git-commands"), "utf8")).toBe( + expect(readVersion(join(fixture.root, "mcp-server/package.json"))).toBe( + "0.48.0-dev.0", + ); + expect(readFileSync(join(fixture.cli, "git-commands"), "utf8")).toBe( "commit\npull\npush\n", ); }); diff --git a/packages/cli/test/script/cli-startup.test.ts b/packages/cli/test/script/cli-startup.test.ts index adf72cbb1..dcdadcef8 100644 --- a/packages/cli/test/script/cli-startup.test.ts +++ b/packages/cli/test/script/cli-startup.test.ts @@ -77,7 +77,7 @@ function bundleStartup( }); } -test.each(["bin.ts", "cli.ts", "index.ts"])( +test.each(["bin.ts", "cli.ts", "index.ts", "mcp-server.ts"])( "%s does not eagerly import the Sentry SDK", async (entry) => { // Traverse the pure predicates' source to catch future SDK imports. diff --git a/packages/cli/test/script/help-graphics-bundle.test.ts b/packages/cli/test/script/help-graphics-bundle.test.ts new file mode 100644 index 000000000..6a27d44ee --- /dev/null +++ b/packages/cli/test/script/help-graphics-bundle.test.ts @@ -0,0 +1,29 @@ +/** + * `lib/help.ts` must not import the terminal-image stack: the full CLI + * registers the kitty/sixel banner from `app.ts`, and `@sentry/mcp-server` + * (which bundles help but not `app.ts`) leaves it out. See `GraphicsBanner` + * in `lib/help.ts`. + */ + +import { join } from "node:path"; +import { build } from "esbuild"; +import { expect, test } from "vitest"; + +const IMAGE_STACK_RE = + /(?:src[\\/]lib[\\/](?:sixel|sixel-image|kitty-image)\.ts|src[\\/]generated[\\/]banner-image\.ts|node_modules[\\/].*(?:pngjs|jpeg-js)[\\/])/; + +test("help does not bundle sixel, kitty, pngjs, or jpeg-js", async () => { + const { metafile } = await build({ + entryPoints: [join(import.meta.dirname, "../../src/lib/help.ts")], + bundle: true, + write: false, + platform: "node", + format: "esm", + packages: "bundle", + metafile: true, + logLevel: "silent", + }); + const inputs = Object.keys(metafile.inputs); + expect(inputs.some((path) => path.endsWith("lib/help.ts"))).toBe(true); + expect(inputs.filter((path) => IMAGE_STACK_RE.test(path))).toEqual([]); +}); diff --git a/packages/cli/test/script/syntax-highlight-bundle.test.ts b/packages/cli/test/script/syntax-highlight-bundle.test.ts index 5a1a26081..417126eaa 100644 --- a/packages/cli/test/script/syntax-highlight-bundle.test.ts +++ b/packages/cli/test/script/syntax-highlight-bundle.test.ts @@ -28,7 +28,7 @@ async function bundleMarkdown(define: Record = {}) { define, plugins: [ { - // Mirrors the require-alias plugin in script/bundle.ts. + // Mirrors script/lib/require-alias-plugin.ts. name: "require-alias", setup(builder) { builder.onLoad({ filter: HIGHLIGHT_MODULE_RE }, async (args) => ({ diff --git a/packages/cli/test/script/zod-v3-stub-plugin.test.ts b/packages/cli/test/script/zod-v3-stub-plugin.test.ts new file mode 100644 index 000000000..3ef5020ae --- /dev/null +++ b/packages/cli/test/script/zod-v3-stub-plugin.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for the esbuild plugin that stubs `zod/v3`. + * + * Bundles the real dependencies that import `zod/v3` (`@mastra/client-js` + * and its `@mastra/schema-compat`, and `@ai-sdk/provider-utils`) with the + * plugin, runs the code paths the CLI and MCP server use, and checks that no + * Zod 3 module reaches the output. + */ + +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { afterAll, describe, expect, test } from "vitest"; +import { zodV3StubPlugin } from "../../script/lib/zod-v3-stub-plugin.js"; + +const require = createRequire(import.meta.url); +const CLI_DIR = join(dirname(fileURLToPath(import.meta.url)), "../.."); +/** `@mastra/client-js`'s own directory, so its dependencies resolve. */ +const MASTRA_CLIENT_DIR = dirname( + realpathSync(require.resolve("@mastra/client-js/package.json")), +); +/** mcp-core depends on `@ai-sdk/provider-utils`, the copy MCP bundles use. */ +const MCP_CORE_DIR = join(CLI_DIR, "../mcp-core"); + +const MASTRA_ENTRY = ` +import { MastraClient } from "@mastra/client-js"; +import { standardSchemaToJSONSchema, toStandardSchema } from "@mastra/schema-compat/schema"; +import { z } from "zod"; +import { ZodFirstPartyTypeKind } from "zod/v3"; + +export async function run() { + const requests = []; + const fetch = async (url, init) => { + requests.push({ url, method: init?.method }); + return new Response(JSON.stringify({ runId: "run-1", status: "success" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + // What \`sentry init\` does: create a workflow run and start it. + const client = new MastraClient({ baseUrl: "http://mastra.test", fetch, retries: 0 }); + const wfRun = await client.getWorkflow("wizard").createRun(); + await wfRun.startAsync({ inputData: { directory: "." } }); + + const fromZod4 = standardSchemaToJSONSchema( + toStandardSchema(z.object({ name: z.string() })), + ); + // A plain JSON Schema reaches isZodV3()'s \`instanceof z3.ZodType\`. + const fromJsonSchema = standardSchemaToJSONSchema( + toStandardSchema({ type: "object", properties: { name: { type: "string" } } }), + ); + let zod3Error; + try { + ZodFirstPartyTypeKind.ZodAny; + } catch (error) { + zod3Error = error.message; + } + return { requests, fromZod4, fromJsonSchema, zod3Error }; +} +`; + +const PROVIDER_UTILS_ENTRY = ` +import { zodSchema } from "@ai-sdk/provider-utils"; +import { z } from "zod"; + +export async function run() { + const schema = zodSchema(z.object({ name: z.string() })); + return { + jsonSchema: await schema.jsonSchema, + validated: await schema.validate({ name: "ok" }), + }; +} +`; + +const outDir = mkdtempSync(join(tmpdir(), "zod-v3-stub-")); +afterAll(() => rmSync(outDir, { recursive: true, force: true })); + +/** Bundle `contents` with the stub, then load and run its `run()` export. */ +async function bundleAndRun( + name: string, + contents: string, + resolveDir: string, +): Promise<{ inputs: string[]; result: Record }> { + const result = await build({ + stdin: { contents, resolveDir, loader: "js" }, + bundle: true, + write: false, + platform: "node", + format: "cjs", + metafile: true, + logLevel: "silent", + plugins: [zodV3StubPlugin], + }); + const file = join(outDir, `${name}.cjs`); + writeFileSync(file, result.outputFiles[0]?.text ?? ""); + const mod = require(file) as { run: () => Promise> }; + return { + inputs: Object.keys(result.metafile.inputs), + result: await mod.run(), + }; +} + +describe("zodV3StubPlugin", () => { + test("keeps Mastra's workflow client and schema conversion working", async () => { + const { inputs, result } = await bundleAndRun( + "mastra", + MASTRA_ENTRY, + MASTRA_CLIENT_DIR, + ); + + expect( + inputs.filter((input) => /[\\/]zod[\\/]v3[\\/]/.test(input)), + ).toEqual([]); + expect(result.requests).toEqual([ + { + url: "http://mastra.test/api/workflows/wizard/create-run?", + method: "POST", + }, + { + url: "http://mastra.test/api/workflows/wizard/start-async?runId=run-1", + method: "POST", + }, + ]); + expect(result.fromZod4).toMatchObject({ + type: "object", + properties: { name: { type: "string" } }, + }); + expect(result.fromJsonSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } }, + }); + expect(result.zod3Error).toBe( + "Zod 3 schemas are not supported in this build (zod/v3 is not bundled).", + ); + }); + + test("keeps the AI SDK's Zod 4 schemas working", async () => { + const { inputs, result } = await bundleAndRun( + "provider-utils", + PROVIDER_UTILS_ENTRY, + MCP_CORE_DIR, + ); + + expect( + inputs.filter((input) => /[\\/]zod[\\/]v3[\\/]/.test(input)), + ).toEqual([]); + expect(result.jsonSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } }, + }); + expect(result.validated).toEqual({ success: true, value: { name: "ok" } }); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts index 05775ec24..ac033948b 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts @@ -464,7 +464,6 @@ async function handleAuthenticatedMcpRequest( buildServer({ context: serverContext, experimentalMode: isExperimentalMode, - sdkVersion: "v2", }), { route: url.pathname, diff --git a/packages/mcp-core/README.md b/packages/mcp-core/README.md index 734fee570..85c56231e 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: @@ -31,22 +31,22 @@ You can limit which skills are granted using the `--skills` flag: ```shell # Default: active skills (inspect, seer, triage, project-management) -npx @sentry/mcp-server@latest --access-token=sentry-user-token +npx @sentry/mcp-server@latest # Limit to specific skills only -npx @sentry/mcp-server@latest --access-token=TOKEN --skills=inspect,docs +npx @sentry/mcp-server@latest --skills=inspect,docs # Grant all active, non-deprecated skills, overriding MCP_SKILLS -npx @sentry/mcp-server@latest --access-token=TOKEN --all-skills +npx @sentry/mcp-server@latest --all-skills # Self-hosted Sentry -npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com +SENTRY_HOST=sentry.example.com npx @sentry/mcp-server@latest # Override OpenAI endpoint for AI-powered tools (stdio only) -npx @sentry/mcp-server@latest --access-token=TOKEN --openai-base-url=https://proxy.example.com/v1 +npx @sentry/mcp-server@latest --openai-base-url=https://proxy.example.com/v1 # Azure OpenAI / Azure-compatible deployment routing (stdio only) -npx @sentry/mcp-server@latest --access-token=TOKEN --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ +npx @sentry/mcp-server@latest --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ ``` For Azure OpenAI or Azure-compatible deployment proxies, use the dedicated @@ -73,7 +73,8 @@ This ensures that only relevant tools are available in constrained contexts. Whe You can also use environment variables: ```shell -SENTRY_ACCESS_TOKEN=your-token +SENTRY_AUTH_TOKEN=your-token # Optional after `auth login` +SENTRY_FORCE_ENV_TOKEN=1 # Use SENTRY_AUTH_TOKEN over a stored login # Optional overrides. Leave unset to use the default SaaS host SENTRY_HOST=sentry.example.com # Self-hosted Sentry hostname MCP_SKILLS=inspect,docs,triage # Limit to specific skills @@ -120,10 +121,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 --scopes=org:read,event:write # NEW (recommended) -npx @sentry/mcp-server --access-token=TOKEN --skills=inspect,triage +npx @sentry/mcp-server@latest --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..d194b987d 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": { @@ -159,6 +163,7 @@ "devDependencies": { "@ai-sdk/provider": "^3.0.6", "@ai-sdk/provider-utils": "^4.0.11", + "@modelcontextprotocol/sdk": "catalog:", "@sentry/mcp-server-mocks": "workspace:*", "@sentry/mcp-server-tsconfig": "workspace:*", "msw": "catalog:", @@ -171,7 +176,6 @@ "@ai-sdk/openai": "catalog:", "@logtape/logtape": "^1.1.1", "@logtape/sentry": "^1.1.1", - "@modelcontextprotocol/sdk": "catalog:", "@modelcontextprotocol/server": "catalog:", "@sentry/api": "catalog:", "@sentry/core": "catalog:", diff --git a/packages/mcp-core/src/server-capabilities.test.ts b/packages/mcp-core/src/server-capabilities.test.ts index 74945c31a..ae4f2b37d 100644 --- a/packages/mcp-core/src/server-capabilities.test.ts +++ b/packages/mcp-core/src/server-capabilities.test.ts @@ -81,10 +81,9 @@ describe("static tool catalog capabilities", () => { }); it("does not advertise or acknowledge modern tool-list subscriptions", async () => { - const handler = createMcpHandler( - () => buildServer({ context, tools, sdkVersion: "v2" }), - { keepAliveMs: 0 }, - ); + const handler = createMcpHandler(() => buildServer({ context, tools }), { + keepAliveMs: 0, + }); try { const discovery = await handler.fetch(modernRequest("server/discover")); expect(discovery.status).toBe(200); diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index f6051172b..c7a0c9d09 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -1,6 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { McpServer as ModernMcpServer } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/server"; import { type Span, setUser, startSpan } from "@sentry/core"; import { issueFixture, @@ -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"; @@ -95,7 +96,7 @@ async function listRegisteredTools(server: ReturnType) { } async function callRegisteredTool( - server: ReturnType | ModernMcpServer, + server: ReturnType, name: string, args: Record, ) { @@ -176,6 +177,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(); @@ -229,10 +272,9 @@ describe("buildServer", () => { handler: async ({ value }) => `v2:${String(value)}`, }), }, - sdkVersion: "v2", }); - expect(server).toBeInstanceOf(ModernMcpServer); + expect(server).toBeInstanceOf(McpServer); const registeredTools = ( server as unknown as { _registeredTools: Record< @@ -252,25 +294,19 @@ describe("buildServer", () => { }); }); - it.each(["v1", "v2"] as const)( - "explains how to recover a missing organizationSlug on SDK %s", - async (sdkVersion) => { - const server = - sdkVersion === "v2" - ? buildServer({ context: baseContext, sdkVersion }) - : buildServer({ context: baseContext }); + it("explains how to recover a missing organizationSlug", async () => { + const server = buildServer({ context: baseContext }); - const result = await callRegisteredTool(server, "search_issues", { - query: "is:unresolved", - period: "1h", - }); + const result = await callRegisteredTool(server, "search_issues", { + query: "is:unresolved", + period: "1h", + }); - expect(result).toMatchObject({ isError: true }); - const text = getTextContent(result); - expect(text).toContain("Required. Call `find_organizations()`"); - expect(text).not.toContain("period"); - }, - ); + expect(result).toMatchObject({ isError: true }); + const text = getTextContent(result); + expect(text).toContain("Required. Call `find_organizations()`"); + expect(text).not.toContain("period"); + }); describe("telemetry context", () => { it("generates compatibility text for structured-only tool output", async () => { @@ -1378,48 +1414,42 @@ describe("buildServer", () => { ); }); - it.each(["v1", "v2"] as const)( - "execute_sentry_tool rejects missing skills with unknown capabilities on SDK %s", - async (sdkVersion) => { - const upstreamRequest = vi.fn(() => - HttpResponse.json( - { detail: "You do not have permission to perform this action." }, - { status: 403 }, - ), - ); - mswServer.use( - http.get( - "https://us.sentry.io/api/0/projects/test-org/test-project/profiling/profiles/:profileId/", - upstreamRequest, - ), - ); - const context: ServerContext = { - ...baseContext, - grantedSkills: new Set(["docs"]), - constraints: { - organizationSlug: "test-org", - projectSlug: "test-project", - regionUrl: "https://us.sentry.io", - projectCapabilities: null, - }, - }; - const server = - sdkVersion === "v2" - ? buildServer({ context, sdkVersion }) - : buildServer({ context }); + it("execute_sentry_tool rejects missing skills with unknown capabilities", async () => { + const upstreamRequest = vi.fn(() => + HttpResponse.json( + { detail: "You do not have permission to perform this action." }, + { status: 403 }, + ), + ); + mswServer.use( + http.get( + "https://us.sentry.io/api/0/projects/test-org/test-project/profiling/profiles/:profileId/", + upstreamRequest, + ), + ); + const context: ServerContext = { + ...baseContext, + grantedSkills: new Set(["docs"]), + constraints: { + organizationSlug: "test-org", + projectSlug: "test-project", + regionUrl: "https://us.sentry.io", + projectCapabilities: null, + }, + }; + const server = buildServer({ context }); - const result = await callRegisteredTool(server, "execute_sentry_tool", { - name: "get_profile_details", - arguments: { profileId: "00000000000040008000000000000001" }, - }); + const result = await callRegisteredTool(server, "execute_sentry_tool", { + name: "get_profile_details", + arguments: { profileId: "00000000000040008000000000000001" }, + }); - expect(result).toMatchObject({ isError: true }); - expect(getTextContent(result)).toContain( - 'Tool "get_profile_details" is not available in this session', - ); - expect(upstreamRequest).not.toHaveBeenCalled(); - }, - ); + expect(result).toMatchObject({ isError: true }); + expect(getTextContent(result)).toContain( + 'Tool "get_profile_details" is not available in this session', + ); + expect(upstreamRequest).not.toHaveBeenCalled(); + }); it("search_sentry_tools and execute_sentry_tool enforce project capabilities by default", async () => { const server = buildServer({ diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index fc8df3d30..a8d0187c9 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -1,4 +1,3 @@ -import { McpServer as LegacyMcpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; /** * MCP Server Configuration and Request Handling Infrastructure. * @@ -19,8 +18,7 @@ import { McpServer as LegacyMcpServer } from "@modelcontextprotocol/sdk/server/m * }); * ``` */ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { McpServer as ModernMcpServer } from "@modelcontextprotocol/server"; +import { type CallToolResult, McpServer } from "@modelcontextprotocol/server"; import { getActiveSpan, type SpanAttributeValue, @@ -113,7 +111,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, @@ -124,57 +122,28 @@ function structuredOutputToCallToolResult( * }; * * const server = buildServer({ context }); - * await startStdio(server, context); + * const { closed } = await startStdio(server, context); + * await closed; * ``` * */ -type McpServer = LegacyMcpServer | ModernMcpServer; - -type BuildServerOptions = { - context: ServerContext; - experimentalMode?: boolean; - tools?: ToolRegistry; -}; - -export function buildServer( - options: BuildServerOptions & { sdkVersion: "v2" }, -): ModernMcpServer; -export function buildServer(options: BuildServerOptions): LegacyMcpServer; export function buildServer({ context, experimentalMode = false, tools: customTools, - sdkVersion = "v1", -}: BuildServerOptions & { - sdkVersion?: "v1" | "v2"; +}: { + context: ServerContext; + experimentalMode?: boolean; + tools?: ToolRegistry; }): McpServer { const contextWithModes: ServerContext = { ...context, experimentalMode, }; - const serverInfo = { + const server = new McpServer({ name: MCP_SERVER_NAME, version: LIB_VERSION, - }; - - if (sdkVersion === "v2") { - const server = new ModernMcpServer(serverInfo); - const registrations = configureServer({ - server, - context: contextWithModes, - experimentalMode, - tools: customTools, - }); - for (const { name, config, handler } of registrations) { - server.registerTool(name, config, handler); - } - if (registrations.length > 0) { - server.server.registerCapabilities({ tools: { listChanged: false } }); - } - return wrapMcpServerWithSentry(server); - } - - const server = new LegacyMcpServer(serverInfo); + }); const registrations = configureServer({ server, context: contextWithModes, @@ -401,14 +370,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/tools/catalog-runtime/availability.ts b/packages/mcp-core/src/tools/catalog-runtime/availability.ts index 7d3feff6c..a2f4c050e 100644 --- a/packages/mcp-core/src/tools/catalog-runtime/availability.ts +++ b/packages/mcp-core/src/tools/catalog-runtime/availability.ts @@ -1,9 +1,4 @@ -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { getClient } from "@sentry/core"; -import type { - ServerNotification, - ServerRequest, -} from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { UserInputError } from "../../errors"; import { @@ -285,8 +280,3 @@ export function resolveToolDescription( ): string { return resolveDescription(tool.description, context); } - -export type RegisteredToolHandlerExtra = RequestHandlerExtra< - ServerRequest, - ServerNotification ->; diff --git a/packages/mcp-core/src/tools/catalog/get-event-attachment.ts b/packages/mcp-core/src/tools/catalog/get-event-attachment.ts index dd124610a..8aaed8199 100644 --- a/packages/mcp-core/src/tools/catalog/get-event-attachment.ts +++ b/packages/mcp-core/src/tools/catalog/get-event-attachment.ts @@ -2,7 +2,7 @@ import type { EmbeddedResource, ImageContent, TextContent, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; import { DEFAULT_MAX_INLINE_ATTACHMENT_BYTES } from "../../api-client"; import { bytesToBase64 } from "../../internal/blob-utils"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; diff --git a/packages/mcp-core/src/tools/catalog/get-snapshot-image.test.ts b/packages/mcp-core/src/tools/catalog/get-snapshot-image.test.ts index f8a0d31ac..50ab80113 100644 --- a/packages/mcp-core/src/tools/catalog/get-snapshot-image.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-snapshot-image.test.ts @@ -1,7 +1,4 @@ -import type { - ImageContent, - TextContent, -} from "@modelcontextprotocol/sdk/types.js"; +import type { ImageContent, TextContent } from "@modelcontextprotocol/server"; import { mswServer } from "@sentry/mcp-server-mocks"; import { decode as decodePng, encode as encodePng } from "fast-png"; import { http, HttpResponse } from "msw"; diff --git a/packages/mcp-core/src/tools/support/snapshots/handlers.ts b/packages/mcp-core/src/tools/support/snapshots/handlers.ts index 856168f31..8b142a8f6 100644 --- a/packages/mcp-core/src/tools/support/snapshots/handlers.ts +++ b/packages/mcp-core/src/tools/support/snapshots/handlers.ts @@ -1,7 +1,4 @@ -import type { - ImageContent, - TextContent, -} from "@modelcontextprotocol/sdk/types.js"; +import type { ImageContent, TextContent } from "@modelcontextprotocol/server"; import type { SentryApiService } from "../../../api-client"; import { UserInputError } from "../../../errors"; import { diff --git a/packages/mcp-core/src/tools/types.ts b/packages/mcp-core/src/tools/types.ts index 314a14efc..cf2f1657b 100644 --- a/packages/mcp-core/src/tools/types.ts +++ b/packages/mcp-core/src/tools/types.ts @@ -3,7 +3,7 @@ import type { EmbeddedResource, ImageContent, TextContent, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; import type { z } from "zod"; import type { Scope } from "../permissions"; import type { Skill } from "../skills"; diff --git a/packages/mcp-core/src/transports/stdio.ts b/packages/mcp-core/src/transports/stdio.ts new file mode 100644 index 000000000..cff94e352 --- /dev/null +++ b/packages/mcp-core/src/transports/stdio.ts @@ -0,0 +1,90 @@ +/** + * 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 }); + * const { closed } = await startStdio(server, context); + * await closed; + * ``` + * + * Uses MCP SDK v2, like the Cloudflare worker. Its transport closes when the + * client closes stdin, which is what resolves `closed`. + */ +import type { McpServer } from "@modelcontextprotocol/server"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +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; +} + +/** A connected stdio server. */ +export type StdioConnection = { + /** Resolves when the transport closes, e.g. after the client closes stdin. */ + closed: Promise; +}; + +/** + * 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, +): Promise { + const transport = new StdioServerTransport(); + await startNewTrace(async () => { + return await startSpan( + { + name: "mcp.server/stdio", + attributes: getStdioSpanAttributes(context), + }, + async () => { + await server.connect(transport); + }, + ); + }); + // `connect()` installs the server's own close handler; chain onto it. + const closed = new Promise((resolve) => { + const onServerClose = transport.onclose; + transport.onclose = () => { + onServerClose?.(); + resolve(); + }; + }); + return { closed }; +} 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..778274356 100644 --- a/packages/mcp-server-evals/package.json +++ b/packages/mcp-server-evals/package.json @@ -17,9 +17,7 @@ "dependencies": { "@ai-sdk/mcp": "catalog:", "@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..e6ff0fdc4 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"; @@ -36,7 +36,10 @@ const context: ServerContext = { }, }; -const server = buildServer({ context, experimentalMode: true }); +const server = buildServer({ + context, + experimentalMode: true, +}); // Run in-process MCP with all skills so MSW mocks apply startStdio(server, context).catch((err: unknown) => { diff --git a/packages/mcp-server-evals/src/evals/utils/mcpToolCallRunner.ts b/packages/mcp-server-evals/src/evals/utils/mcpToolCallRunner.ts index 4704df7fe..c44bd64bb 100644 --- a/packages/mcp-server-evals/src/evals/utils/mcpToolCallRunner.ts +++ b/packages/mcp-server-evals/src/evals/utils/mcpToolCallRunner.ts @@ -25,7 +25,6 @@ export function McpToolCallTaskRunner( args: ["--filter", "@sentry/mcp-server-evals", "start"], env: { ...process.env, - SENTRY_ACCESS_TOKEN: "mocked-access-token", SENTRY_HOST: "sentry.io", }, }); diff --git a/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts b/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts index c3e7dbc12..098cbfe8a 100644 --- a/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts +++ b/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts @@ -1,9 +1,12 @@ +import { fileURLToPath } from "node:url"; import { generateObject, type LanguageModel } from "ai"; import { z } from "zod"; import { experimental_createMCPClient } from "@ai-sdk/mcp"; import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio"; import { getOpenRouterModel } from "@sentry/mcp-core/internal/agents/openrouter-provider"; +const CLI_DIR = fileURLToPath(new URL("../../../../cli", import.meta.url)); + // Cache for available tools to avoid reconnecting for each test let cachedTools: string[] | null = null; @@ -16,18 +19,18 @@ async function getAvailableTools(): Promise { return cachedTools; } - // Use pnpm exec to run the binary from the workspace + // An inherited deprecated `SENTRY_ACCESS_TOKEN` would replace the mock + // token below, and could be scoped to another host than sentry.io. + const { SENTRY_ACCESS_TOKEN: _deprecatedToken, ...env } = process.env; + + // Run `sentry mcp` from the workspace CLI source. const transport = new Experimental_StdioMCPTransport({ command: "pnpm", - args: [ - "exec", - "sentry-mcp", - "--access-token=mocked-access-token", - "--all-skills", - ], + args: ["--silent", "--dir", CLI_DIR, "run", "cli", "mcp", "--all-skills"], env: { - ...process.env, - SENTRY_ACCESS_TOKEN: "mocked-access-token", + ...env, + SENTRY_AUTH_TOKEN: "mocked-access-token", + SENTRY_FORCE_ENV_TOKEN: "1", SENTRY_HOST: "sentry.io", }, }); diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..38a75e56a --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,56 @@ +# @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. +- To use a token instead, set `SENTRY_AUTH_TOKEN` in the client's `env`, with + `SENTRY_FORCE_ENV_TOKEN=1` so it wins over a stored login: + + ```json + { + "command": "npx", + "args": ["-y", "@sentry/mcp-server@latest"], + "env": { + "SENTRY_AUTH_TOKEN": "sntryu_...", + "SENTRY_FORCE_ENV_TOKEN": "1" + } + } + ``` + +- `--access-token` / `SENTRY_ACCESS_TOKEN` are deprecated. They still work, as + `SENTRY_AUTH_TOKEN` plus `SENTRY_FORCE_ENV_TOKEN=1`, and print a warning on + stderr. +- Each server process uses one account. The package runs the CLI's own + `auth` commands, so `npx -y @sentry/mcp-server auth status|list|switch|logout` + manages the same login. + +## Options + +Starting the server is the default command, so `npx -y @sentry/mcp-server` +and `npx -y @sentry/mcp-server mcp` are the same. Run +`npx -y @sentry/mcp-server mcp --help` for the flags and environment +variables. For self-hosted Sentry, set `SENTRY_HOST` / `SENTRY_URL` (with +`SENTRY_AUTH_TOKEN`, only these scope the token to your server). See +[stdio authentication](https://github.com/getsentry/toolkit/blob/main/docs/operations/stdio-auth.md). + +## Development + +The source lives in `packages/cli`: `packages/cli/src/mcp-server.ts` runs the +CLI's runner with an application of three commands, `mcp`, `auth`, and +`help` (`packages/cli/src/mcp-server-app.ts`). `pnpm run bundle` builds +`dist/sentry-mcp.cjs` from it, 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..584cdc4a1 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,71 +1,50 @@ { "name": "@sentry/mcp-server", "mcpName": "io.github.getsentry/sentry-mcp", - "version": "0.42.0", + "version": "0.48.0-dev.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:" + "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs && pnpm --filter sentry run generate:sdk && tsx script/bundle.ts", + "test:bundle": "node --test test/bundle.test.mjs" }, "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:" + "esbuild": "^0.28.1", + "tsx": "catalog:" } } diff --git a/packages/mcp-server/script/bundle.ts b/packages/mcp-server/script/bundle.ts new file mode 100644 index 000000000..d3284c10a --- /dev/null +++ b/packages/mcp-server/script/bundle.ts @@ -0,0 +1,174 @@ +#!/usr/bin/env tsx +/** + * Bundle the CLI's `src/mcp-server.ts` into this package's `dist/sentry-mcp.cjs`. + * + * The entry runs the CLI's own runner (`cli.ts`) with a smaller route map: + * `mcp` (the default command), `auth`, and `help`. So the package ships the + * same server and the same `auth` commands as the CLI, but none of the other + * commands. 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 "../../cli/script/lib/require-alias-plugin.js"; +import { zodEnglishLocalesPlugin } from "../../cli/script/lib/zod-locales-plugin.js"; +import { zodV3StubPlugin } from "../../cli/script/lib/zod-v3-stub-plugin.js"; +import { textImportPlugin } from "../../cli/script/text-import-plugin.js"; + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const CLI_ROOT = resolve(PACKAGE_ROOT, "../cli"); +const OUTFILE = resolve(PACKAGE_ROOT, "dist/sentry-mcp.cjs"); + +/** + * Output is 3,624,475 bytes (3.46 MiB) once help's image banner is left out. + * The budget leaves about 300 KB for new tools and commands, not enough for a + * heavy CLI dependency to slip in unnoticed. + */ +const MAX_BUNDLE_BYTES = 3.75 * 1024 * 1024; + +/** + * Inputs that serve the CLI but not this package: commands other than `mcp`, + * `auth`, and `help`, agent skills, shell completions, syntax highlighting, + * the Ink UI, terminal images (the kitty/sixel help banner and dashboards, + * and `pngjs`), `sentry init`, build/sourcemap/debug-file processing, the + * SQLite WASM fallback for Node.js < 22.15, Zod 3, and MCP SDK v1. + * + * `jpeg-js` is not on the list: mcp-core's snapshot and attachment tools + * decode and encode JPEGs with it (`internal/blob-utils.ts`). + */ +const FORBIDDEN_INPUTS: ReadonlyArray = [ + /[\\/]packages[\\/]cli[\\/]src[\\/](?:app\.ts$|commands[\\/](?!auth[\\/]|help\.ts$|mcp\.ts$))/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:agent-skills|complete|completions)\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:sixel|sixel-image|kitty-image|formatters[\\/]sixel-dashboard)\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/]generated[\\/]banner-image\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:bash-hook|build|dif|init|react-native|snapshots|sourcemap|wasm)[\\/]/, + /[\\/]node_modules[\\/](?:cli-highlight|highlight\.js|parse5|ink|ink-spinner|react|react-reconciler|yoga-layout|@sentry[\\/]symbolic|node-sqlite3-wasm|pngjs)[\\/]/, + /[\\/]node_modules[\\/](?:zod[\\/]v3|@modelcontextprotocol[\\/]sdk)[\\/]/, +]; + +/** 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)}}`; + +/** + * Packages that only CLI-only code paths import: the SQLite WASM fallback + * (Node.js < 22.15, which the version check rejects), the symbolic WASM + * binary, and the Ink UI. They resolve to an empty module instead of a + * `require()` of a package the tarball does not ship, so no dangling import + * reaches the output. `zod/v3` is stubbed by {@link zodV3StubPlugin}, shared + * with the CLI bundles. + */ +const STUBBED_MODULES = + /^(?:node-sqlite3-wasm|@sentry\/symbolic\/symbolic_bg\.wasm|ink|ink-spinner|react|react-reconciler|react-devtools-core|yoga-layout)(?:\/.*)?$/; + +const stubModulesPlugin: Plugin = { + name: "stub-cli-only-modules", + setup(b) { + b.onResolve({ filter: STUBBED_MODULES }, (args) => ({ + path: args.path, + namespace: "stub", + })); + b.onLoad({ filter: /.*/, namespace: "stub" }, () => ({ + contents: "module.exports = {};", + loader: "js", + })); + }, +}; + +/** 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 }; + +// Each CLI release publishes this package at the CLI's version, set by +// `packages/cli/script/bump-version.ts`. +if (pkg.version !== cliPkg.version) { + console.error( + `\nError: @sentry/mcp-server is at ${pkg.version} but the CLI is at ${cliPkg.version}. Both must share one version.\n`, + ); + process.exit(1); +} + +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), + // 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", + // Code blocks in help and auth output render without colors, so + // highlight.js and parse5 stay out (see formatters/highlight.ts). + __SENTRY_CLI_SYNTAX_HIGHLIGHT__: "false", + // `auth switch` and `auth login` ask for an account by argument instead + // of the Ink picker (see lib/select-prompt.ts). + __SENTRY_CLI_INK_UI__: "false", + "import.meta.url": "import_meta_url", + }, + external: ["node:*"], + metafile: true, + plugins: [ + stubModulesPlugin, + textImportPlugin, + requireAliasPlugin, + zodEnglishLocalesPlugin, + zodV3StubPlugin, + ], +}); + +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(2)} MB budget.\n`, + ); + process.exit(1); +} + +console.log( + ` -> ${OUTFILE.slice(PACKAGE_ROOT.length + 1)} (${(bytes / 1024).toFixed(1)} KB)`, +); 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 db7d1959d..000000000 --- a/packages/mcp-server/src/index.ts +++ /dev/null @@ -1,474 +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"; - -// Keep static imports light: `--help`, `--version`, and argument errors must -// not load the tools, the AI SDK, or the Sentry SDK. The server stack is -// imported in runMcpServer() once the server is about to start. -import { SKILLS } from "@sentry/mcp-core/skills"; -import { LIB_VERSION } from "@sentry/mcp-core/version"; -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"; - -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`."), - ); - } - const { authCommand } = await import("./cli/commands/auth"); - 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, - })) - : import("./auth/resolve-token").then(({ resolveAccessToken }) => - resolveAccessToken(partialCfg), - ) - ).catch((err) => { - die(err); - }); - - const [ - { - getAgentProvider, - getResolvedProviderType, - setAgentProvider, - setProviderBaseUrls, - }, - { buildServer }, - { sentryBeforeSend }, - Sentry, - { startStdio }, - ] = await Promise.all([ - import("@sentry/mcp-core/internal/agents/provider-factory"), - import("@sentry/mcp-core/server"), - import("@sentry/mcp-core/telem/sentry"), - import("@sentry/node"), - import("./transports/stdio"), - ]); - - // 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..9190b3d27 --- /dev/null +++ b/packages/mcp-server/test/bundle.test.mjs @@ -0,0 +1,243 @@ +/** + * 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 { builtinModules } from "node:module"; +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("the bundle requires only Node.js built-ins", () => { + const builtins = new Set(builtinModules); + // Skip `require(...)` inside string literals: ajv generates code with them. + const required = [ + ...readFileSync(bin, "utf-8").matchAll( + /(? id); + const external = required.filter( + (id) => !(id.startsWith("node:") || builtins.has(id.split("/")[0])), + ); + assert.deepEqual([...new Set(external)], []); +}); + +for (const flag of ["--version", "-v"]) { + test(`${flag} prints the package version`, () => { + const result = run([flag]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), version); + }); +} + +test("--help lists only mcp, auth, and help", () => { + const result = run(["--help"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^USAGE\n {2}sentry-mcp mcp /); + const commands = result.stdout.split("COMMANDS\n")[1] ?? ""; + assert.deepEqual( + commands + .trim() + .split("\n") + .map((line) => line.trim().split(/\s+/)[0]), + ["mcp", "auth", "help"], + ); +}); + +test("mcp --help examples name this binary", () => { + const result = run(["mcp", "--help"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^ {2}sentry-mcp mcp --all-skills /m); + assert.doesNotMatch(result.stdout, /^ {2}sentry mcp/m); +}); + +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, /'sentry-mcp auth login'/); +}); + +test("under npx, the login hint goes through npx", () => { + const result = run([], { npm_command: "exec" }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /'npx -y @sentry\/mcp-server auth login'/); +}); + +test("runs the CLI's auth commands", () => { + const result = run(["auth", "status"]); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /Not authenticated\. Run 'sentry-mcp auth login'/, + ); +}); + +test("rejects the CLI's other commands", () => { + const result = run(["issue", "list"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /issue/); +}); + +/** + * Start the server, run initialize + tools/list, and close stdin. Resolves + * with the two responses and everything the server wrote to stderr. + * + * With `deletedCwd`, the server starts in a directory removed just before + * `exec`, so `process.cwd()` throws. The shell still exports `PWD`, as an + * interactive shell would. + */ +async function serveStdio(args, extraEnv, { deletedCwd } = {}) { + const node = [process.execPath, bin, ...args]; + const [command, ...commandArgs] = deletedCwd + ? [ + "/bin/sh", + "-c", + 'cd "$0" && rmdir "$0" && exec "$@"', + deletedCwd, + ...node, + ] + : node; + const child = spawn(command, commandArgs, { + env: { ...env, ...extraEnv }, + stdio: ["pipe", "pipe", "pipe"], + }); + const byId = new Map(); + let buffer = ""; + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const responses = new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code) => + reject( + new Error(`server exited (${code}) before responding:\n${stderr}`), + ), + ); + child.stdout.on("data", (chunk) => { + buffer += chunk; + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + // Every stdout line must be a JSON-RPC frame. + 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)); + // The server exits once the client closes stdin. + const exited = new Promise((resolve) => child.on("exit", resolve)); + child.stdin.end(); + assert.equal(await exited, 0); + return { stderr }; + } finally { + child.kill(); + } +} + +test("serves MCP over stdio with SENTRY_AUTH_TOKEN", async () => { + // `--sentry-dsn=` (no telemetry) is a form MCP configs have always used. + const { stderr } = await serveStdio(["--sentry-dsn="], { + SENTRY_AUTH_TOKEN: "sntryu_test", + SENTRY_FORCE_ENV_TOKEN: "1", + }); + assert.doesNotMatch(stderr, /deprecated/); +}); + +test("serves the deprecated --access-token with a hint on stderr", async () => { + const token = "sntryu_deprecated_flag_secret"; + const { stderr } = await serveStdio( + [`--access-token=${token}`, "--sentry-dsn="], + {}, + ); + assert.match( + stderr, + /--access-token is deprecated; set SENTRY_AUTH_TOKEN \(and SENTRY_FORCE_ENV_TOKEN=1 to override a stored login\) instead\./, + ); + assert.equal(stderr.match(/is deprecated/g)?.length, 1); + assert.ok(!stderr.includes(token), "the hint must not echo the token"); +}); + +test( + "serves MCP from a deleted working directory", + { skip: process.platform === "win32" && "needs a POSIX shell" }, + async () => { + await serveStdio( + [], + { SENTRY_AUTH_TOKEN: "sntryu_test", SENTRY_FORCE_ENV_TOKEN: "1" }, + { deletedCwd: mkdtempSync(join(tmpdir(), "sentry-mcp-cwd-")) }, + ); + }, +); 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..4c9b21b4e 100644 --- a/packages/mcp-test-client/README.md +++ b/packages/mcp-test-client/README.md @@ -124,13 +124,15 @@ SENTRY_ACCESS_TOKEN=your_token pnpm mcp-test-client --transport stdio # Test stdio auth/tool discovery without an LLM provider key pnpm mcp-test-client --transport stdio --list-tools -``` -When no token is provided in stdio mode: +# Run the built @sentry/mcp-server bundle instead of `sentry mcp` from source +# (build it first: pnpm --filter @sentry/mcp-server run bundle) +pnpm mcp-test-client --transport stdio --stdio-bundle --list-tools +``` -- `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 +204,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 +224,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/index.ts b/packages/mcp-test-client/src/index.ts index b90d44839..03ca57223 100644 --- a/packages/mcp-test-client/src/index.ts +++ b/packages/mcp-test-client/src/index.ts @@ -56,6 +56,10 @@ program .option("--host ", "Sentry host for stdio transport") .option("--sentry-dsn ", "Sentry DSN for error reporting") .option("--experimental", "Enable experimental tools (/mcp?experimental=1)") + .option( + "--stdio-bundle", + "Run the built @sentry/mcp-server bundle instead of `sentry mcp` from CLI source", + ) .option( "--list-tools", "Connect to the MCP server, list available tools, and exit without using an LLM", @@ -136,6 +140,7 @@ program host: sentryHost, sentryDsn: sentryDsn, useExperimental: options.experimental, + useBundle: options.stdioBundle, }); } else { connection = await connectToRemoteMCPServer({ diff --git a/packages/mcp-test-client/src/mcp-test-client.ts b/packages/mcp-test-client/src/mcp-test-client.ts index 635940a3e..bffe4c26c 100644 --- a/packages/mcp-test-client/src/mcp-test-client.ts +++ b/packages/mcp-test-client/src/mcp-test-client.ts @@ -6,8 +6,12 @@ import { startNewTrace, startSpan } from "@sentry/core"; import { logSuccess } from "./logger.js"; import type { MCPConnection, MCPConfig } from "./types.js"; import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; import { LIB_VERSION } from "./version.js"; -import { buildStdioServerLaunchConfig } from "./transport.js"; +import { + buildStdioServerCommand, + buildStdioServerLaunchConfig, +} from "./transport.js"; export async function connectToMCPServer( config: MCPConfig, @@ -26,17 +30,20 @@ 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", + const server = buildStdioServerCommand( + config, + join(dirname(fileURLToPath(import.meta.url)), "../.."), ); + if (server.bundlePath && !existsSync(server.bundlePath)) { + throw new Error( + `${server.bundlePath} not found. Build it with \`pnpm --filter @sentry/mcp-server run bundle\`.`, + ); + } const launchConfig = buildStdioServerLaunchConfig(config); const transport = new Experimental_StdioMCPTransport({ - command: "node", - args: [mcpServerPath, ...launchConfig.args], + command: server.command, + args: [...server.args, ...launchConfig.args], env: launchConfig.env, }); diff --git a/packages/mcp-test-client/src/transport.test.ts b/packages/mcp-test-client/src/transport.test.ts index b7d61c7b0..bda4b9db9 100644 --- a/packages/mcp-test-client/src/transport.test.ts +++ b/packages/mcp-test-client/src/transport.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildStdioServerCommand, buildStdioServerLaunchConfig, resolveTransportMode, } from "./transport.js"; @@ -61,20 +62,42 @@ describe("buildStdioServerLaunchConfig", () => { }, { PATH: "/usr/bin", + SENTRY_ACCESS_TOKEN: "stale-token", + SENTRY_TOKEN: "other-token", }, ); expect(launchConfig.args).toEqual([ - "--access-token=token-123", "--host=us.sentry.io", "--sentry-dsn=https://dsn.example/1", "--experimental", ]); expect(launchConfig.env).toEqual({ PATH: "/usr/bin", - SENTRY_ACCESS_TOKEN: "token-123", + SENTRY_AUTH_TOKEN: "token-123", + SENTRY_FORCE_ENV_TOKEN: "1", SENTRY_HOST: "us.sentry.io", SENTRY_DSN: "https://dsn.example/1", }); }); }); + +describe("buildStdioServerCommand", () => { + it("runs `sentry mcp` from CLI source by default", () => { + expect(buildStdioServerCommand({}, "/repo/packages")).toEqual({ + command: "pnpm", + args: ["--silent", "--dir", "/repo/packages/cli", "run", "cli", "mcp"], + }); + }); + + it("runs the built @sentry/mcp-server bundle with Node", () => { + const bundlePath = "/repo/packages/mcp-server/dist/sentry-mcp.cjs"; + expect( + buildStdioServerCommand({ useBundle: true }, "/repo/packages"), + ).toEqual({ + command: process.execPath, + args: [bundlePath], + bundlePath, + }); + }); +}); diff --git a/packages/mcp-test-client/src/transport.ts b/packages/mcp-test-client/src/transport.ts index 85ab4e07c..f4789e2a4 100644 --- a/packages/mcp-test-client/src/transport.ts +++ b/packages/mcp-test-client/src/transport.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import type { MCPConfig, RequestedTransportMode } from "./types.js"; export function resolveTransportMode(options: { @@ -22,9 +23,6 @@ export function buildStdioServerLaunchConfig( } { const args: string[] = []; - if (config.accessToken) { - args.push(`--access-token=${config.accessToken}`); - } if (config.host) { args.push(`--host=${config.host}`); } @@ -42,8 +40,14 @@ export function buildStdioServerLaunchConfig( } } + // The server's own env token, forced over any stored CLI login. Never on + // argv, where other processes can read it, and never through the deprecated + // SENTRY_ACCESS_TOKEN alias the server would warn about. + delete env.SENTRY_ACCESS_TOKEN; if (config.accessToken) { - env.SENTRY_ACCESS_TOKEN = config.accessToken; + delete env.SENTRY_TOKEN; + env.SENTRY_AUTH_TOKEN = config.accessToken; + env.SENTRY_FORCE_ENV_TOKEN = "1"; } if (config.host) { env.SENTRY_HOST = config.host; @@ -54,3 +58,21 @@ export function buildStdioServerLaunchConfig( return { args, env }; } + +/** + * The process that serves stdio: `sentry mcp` from the workspace CLI source, + * or the built `@sentry/mcp-server` bundle, which is what the package ships. + */ +export function buildStdioServerCommand( + config: Pick, + packagesDir: string, +): { command: string; args: string[]; bundlePath?: string } { + if (config.useBundle) { + const bundlePath = join(packagesDir, "mcp-server/dist/sentry-mcp.cjs"); + return { command: process.execPath, args: [bundlePath], bundlePath }; + } + return { + command: "pnpm", + args: ["--silent", "--dir", join(packagesDir, "cli"), "run", "cli", "mcp"], + }; +} diff --git a/packages/mcp-test-client/src/types.ts b/packages/mcp-test-client/src/types.ts index fdcca2ad0..d2d13b23d 100644 --- a/packages/mcp-test-client/src/types.ts +++ b/packages/mcp-test-client/src/types.ts @@ -15,6 +15,8 @@ export interface MCPConfig { host?: string; sentryDsn?: string; useExperimental?: boolean; + /** Run the built `@sentry/mcp-server` bundle instead of CLI source. */ + useBundle?: boolean; } export interface RemoteMCPConfig { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa313d799..c2b1fc396 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -474,9 +474,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) @@ -796,9 +796,6 @@ importers: '@logtape/sentry': specifier: ^1.1.1 version: 1.1.1(@logtape/logtape@1.1.1) - '@modelcontextprotocol/sdk': - specifier: 1.32.2 - version: 1.32.2(@cfworker/json-schema@4.1.1)(zod@4.6.7) '@modelcontextprotocol/server': specifier: 'catalog:' version: 2.4.0 @@ -836,6 +833,9 @@ importers: '@ai-sdk/provider-utils': specifier: ^4.0.11 version: 4.0.11(zod@4.6.7) + '@modelcontextprotocol/sdk': + specifier: 1.32.2 + version: 1.32.2(@cfworker/json-schema@4.1.1)(zod@4.6.7) '@sentry/mcp-server-mocks': specifier: workspace:* version: link:../mcp-server-mocks @@ -853,50 +853,13 @@ importers: version: 2.8.3 packages/mcp-server: - dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.32.2 - version: 1.32.2(@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) + esbuild: + specifier: ^0.28.1 + version: 0.28.2 tsx: specifier: 'catalog:' version: 4.20.3 - typescript: - specifier: 'catalog:' - version: 5.9.3 - vitest: - specifier: 'catalog:' - version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@5.0.5)(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-evals: dependencies: @@ -906,15 +869,9 @@ importers: '@ai-sdk/openai': specifier: 'catalog:' version: 3.0.23(zod@4.6.7) - '@modelcontextprotocol/sdk': - specifier: 1.32.2 - version: 1.32.2(@cfworker/json-schema@4.1.1)(zod@4.6.7) '@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/server.json b/server.json index ae9c1b41a..90145a75f 100644 --- a/server.json +++ b/server.json @@ -20,7 +20,13 @@ "isRequired": true, "format": "string", "isSecret": true, - "name": "SENTRY_ACCESS_TOKEN" + "name": "SENTRY_AUTH_TOKEN" + }, + { + "description": "Set to 1 to use SENTRY_AUTH_TOKEN even when a Sentry CLI login is stored", + "format": "string", + "default": "1", + "name": "SENTRY_FORCE_ENV_TOKEN" } ] } 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",