From d1881994866fc09f8c3b2b7216bff1cb354da2aa Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:31:06 +0000 Subject: [PATCH 01/29] ref(mcp): Retire @sentry/mcp-server in favor of sentry mcp Move the stdio MCP host into the CLI so `sentry mcp` is the only local server. The stdio transport moves to mcp-core; flag/env parsing, usage, and server startup move to packages/cli/src/lib/mcp/. The standalone device-code auth and token cache are deleted in favor of CLI auth. `sentry mcp` keeps every standalone flag and env var, and accepts `--access-token` / `SENTRY_ACCESS_TOKEN` as a static, never-refreshed token that still goes through host scoping. Lock the dependency direction (cli -> mcp-core -> toolkit-core) with an oxlint import ban and a workspace manifest cycle check, move the MCP registry entry to the `sentry` package, and reduce @sentry/mcp-server to a deprecation shim for one last release. Co-Authored-By: BYK --- .agents/skills/mcp-qa/SKILL.md | 26 +- .github/workflows/mcp-registry.yml | 18 +- .github/workflows/mcp-server-package.yml | 115 ----- .github/workflows/merge-jobs.yml | 4 +- .gitignore | 2 - .oxlintrc.json | 25 + AGENTS.md | 7 +- README.md | 19 +- ast-grep-rules/no-stdio-stdout.yml | 3 +- docs/architecture/overview.md | 59 ++- docs/contributing/adding-tools.md | 2 +- docs/contributing/api-patterns.md | 6 +- .../integrations/ide-instructions-refactor.md | 2 +- docs/mcp/README.md | 2 +- docs/operations/embedded-agents.md | 18 +- docs/operations/logging.md | 12 +- docs/operations/monitoring.md | 6 +- docs/operations/security.md | 2 +- docs/operations/stdio-auth.md | 186 ++----- docs/releases/cloudflare.md | 2 +- docs/releases/stdio.md | 237 ++------- docs/specs/embedded-agent-openai-routing.md | 15 +- docs/testing/overview.md | 4 +- docs/testing/remote.md | 2 +- docs/testing/stdio.md | 347 +++---------- package.json | 11 +- packages/cli/package.json | 11 +- packages/cli/script/ensure-generated.mjs | 4 +- packages/cli/src/cli.ts | 2 +- packages/cli/src/lib/env-registry.ts | 6 + packages/cli/src/lib/mcp.ts | 84 ++-- packages/cli/src/lib/mcp/args.ts | 184 +++++++ packages/cli/src/lib/mcp/config.ts | 209 ++++++++ packages/cli/src/lib/mcp/server.ts | 365 ++++++++++++++ packages/cli/src/lib/mcp/usage.ts | 70 +++ packages/cli/test/lib/mcp-start.test.ts | 180 +++++-- packages/cli/test/lib/mcp/args.test.ts | 319 ++++++++++++ .../test/lib/mcp/config.test.ts} | 77 ++- packages/cli/test/lib/mcp/usage.test.ts | 88 ++++ .../components/fragments/instructions/amp.tsx | 2 +- .../fragments/instructions/claude-code.tsx | 2 +- .../fragments/instructions/codex-cli.tsx | 2 +- .../fragments/instructions/cursor.tsx | 2 +- .../components/fragments/instructions/fx.tsx | 2 +- .../fragments/instructions/gemini.tsx | 2 +- .../fragments/instructions/opencode.tsx | 2 +- .../components/fragments/instructions/pi.tsx | 2 +- .../fragments/instructions/vscode.tsx | 4 +- .../fragments/instructions/warp.tsx | 2 +- .../components/fragments/instructions/zed.tsx | 2 +- .../components/fragments/stdio-setup.tsx | 19 +- packages/mcp-cloudflare/src/constants.ts | 2 +- packages/mcp-core/README.md | 22 +- packages/mcp-core/package.json | 4 + packages/mcp-core/src/server.ts | 2 +- packages/mcp-core/src/transports/stdio.ts | 71 +++ packages/mcp-server-evals/README.md | 2 +- packages/mcp-server-evals/package.json | 1 - .../src/bin/start-mock-stdio.ts | 2 +- packages/mcp-server/README.md | 21 + packages/mcp-server/bin/sentry-mcp.js | 4 + packages/mcp-server/lib/shim.js | 73 +++ packages/mcp-server/lib/shim.test.js | 37 ++ packages/mcp-server/package.json | 51 +- packages/mcp-server/src/auth/constants.ts | 21 - .../src/auth/device-code-flow.test.ts | 209 -------- .../mcp-server/src/auth/device-code-flow.ts | 201 -------- .../mcp-server/src/auth/resolve-token.test.ts | 147 ------ packages/mcp-server/src/auth/resolve-token.ts | 71 --- .../mcp-server/src/auth/token-cache.test.ts | 118 ----- packages/mcp-server/src/auth/token-cache.ts | 89 ---- packages/mcp-server/src/auth/types.test.ts | 61 --- packages/mcp-server/src/auth/types.ts | 81 --- .../mcp-server/src/cli/commands/auth.test.ts | 38 -- packages/mcp-server/src/cli/commands/auth.ts | 109 ----- packages/mcp-server/src/cli/output.ts | 3 - packages/mcp-server/src/cli/parse.test.ts | 195 -------- packages/mcp-server/src/cli/parse.ts | 125 ----- packages/mcp-server/src/cli/resolve.ts | 171 ------- packages/mcp-server/src/cli/types.ts | 95 ---- packages/mcp-server/src/cli/usage.test.ts | 39 -- packages/mcp-server/src/cli/usage.ts | 123 ----- packages/mcp-server/src/index.ts | 461 ------------------ packages/mcp-server/src/transports/stdio.ts | 97 ---- packages/mcp-server/tsconfig.json | 9 - packages/mcp-server/tsdown.config.ts | 18 - packages/mcp-test-client/README.md | 12 +- .../mcp-test-client/src/mcp-test-client.ts | 21 +- pnpm-lock.yaml | 53 +- scripts/check-workspace-deps.mjs | 159 ++++++ scripts/check-workspace-deps.test.mjs | 73 +++ scripts/oxc-config.test.mjs | 19 + server.json | 15 +- warden.toml | 6 +- 94 files changed, 2363 insertions(+), 3542 deletions(-) delete mode 100644 .github/workflows/mcp-server-package.yml create mode 100644 packages/cli/src/lib/mcp/args.ts create mode 100644 packages/cli/src/lib/mcp/config.ts create mode 100644 packages/cli/src/lib/mcp/server.ts create mode 100644 packages/cli/src/lib/mcp/usage.ts create mode 100644 packages/cli/test/lib/mcp/args.test.ts rename packages/{mcp-server/src/cli/resolve.test.ts => cli/test/lib/mcp/config.test.ts} (85%) create mode 100644 packages/cli/test/lib/mcp/usage.test.ts create mode 100644 packages/mcp-core/src/transports/stdio.ts create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/bin/sentry-mcp.js create mode 100644 packages/mcp-server/lib/shim.js create mode 100644 packages/mcp-server/lib/shim.test.js delete mode 100644 packages/mcp-server/src/auth/constants.ts delete mode 100644 packages/mcp-server/src/auth/device-code-flow.test.ts delete mode 100644 packages/mcp-server/src/auth/device-code-flow.ts delete mode 100644 packages/mcp-server/src/auth/resolve-token.test.ts delete mode 100644 packages/mcp-server/src/auth/resolve-token.ts delete mode 100644 packages/mcp-server/src/auth/token-cache.test.ts delete mode 100644 packages/mcp-server/src/auth/token-cache.ts delete mode 100644 packages/mcp-server/src/auth/types.test.ts delete mode 100644 packages/mcp-server/src/auth/types.ts delete mode 100644 packages/mcp-server/src/cli/commands/auth.test.ts delete mode 100644 packages/mcp-server/src/cli/commands/auth.ts delete mode 100644 packages/mcp-server/src/cli/output.ts delete mode 100644 packages/mcp-server/src/cli/parse.test.ts delete mode 100644 packages/mcp-server/src/cli/parse.ts delete mode 100644 packages/mcp-server/src/cli/resolve.ts delete mode 100644 packages/mcp-server/src/cli/types.ts delete mode 100644 packages/mcp-server/src/cli/usage.test.ts delete mode 100644 packages/mcp-server/src/cli/usage.ts delete mode 100644 packages/mcp-server/src/index.ts delete mode 100644 packages/mcp-server/src/transports/stdio.ts delete mode 100644 packages/mcp-server/tsconfig.json delete mode 100644 packages/mcp-server/tsdown.config.ts create mode 100644 scripts/check-workspace-deps.mjs create mode 100644 scripts/check-workspace-deps.test.mjs diff --git a/.agents/skills/mcp-qa/SKILL.md b/.agents/skills/mcp-qa/SKILL.md index 9b55fd1b4..8858724f5 100644 --- a/.agents/skills/mcp-qa/SKILL.md +++ b/.agents/skills/mcp-qa/SKILL.md @@ -19,29 +19,27 @@ Fix any failures before proceeding. This is the primary QA path for tool behavior. Stdio runs the local MCP server against prod Sentry without depending on the Cloudflare worker, local `/mcp` route, or Cloudflare OAuth configuration. -When validating code or tool changes, build first because the test client -launches `packages/mcp-server/dist/index.js`: +The test client launches `sentry mcp` from CLI source +(`pnpm --dir packages/cli run cli mcp`), which reads mcp-core's build output, so +build first when validating code or tool changes: ```bash pnpm -w run build ``` -Check auth first: +Check auth first. `sentry mcp` uses the CLI's credentials: ```bash -pnpm --filter @sentry/mcp-server start auth status +pnpm --filter sentry run cli auth status ``` -If no cache exists, warm the device-code cache: +If you are not logged in, log in once (or pass `--access-token` to the test +client): ```bash -pnpm --filter @sentry/mcp-server start auth login +pnpm --filter sentry run cli auth login ``` -Device-code auth uses the bundled stdio public client ID, requires no client -secret, is separate from the Cloudflare OAuth app, and caches the token in -`~/.sentry/mcp.json`. - First prove startup and auth: ```bash @@ -96,9 +94,9 @@ For agent-client-specific behavior, replace the default harness prompt with the same realistic prod prompt used for stdio QA. Passing QA requires the same changed-tool transcript evidence, not only `whoami`. -as an isolated auth cache. Real clients do not give stdio subprocesses a TTY, -so warm the cache before running the harness. It also runs the built -`packages/mcp-server/dist/index.js`, so build first to avoid stale code. +Real clients do not give stdio subprocesses a TTY, so log in with +`sentry auth login` before running the harness. Build first to avoid stale +mcp-core code. ## 4. Cloudflare HTTP Only When Relevant @@ -144,7 +142,7 @@ dev-time source execution: ```bash pnpm -w run build -pnpm --filter @sentry/mcp-server start auth status +pnpm --filter sentry run cli auth status pnpm -w run cli --transport stdio "who am I?" ``` diff --git a/.github/workflows/mcp-registry.yml b/.github/workflows/mcp-registry.yml index 4cff8e1c3..22289bc66 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 CLI release (e.g. 0.48.0) required: true type: string @@ -24,8 +24,8 @@ concurrency: 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.repository == 'getsentry/toolkit' && + ((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 +33,7 @@ jobs: contents: read id-token: write env: - VERSION: ${{ inputs.version || github.event.release.tag_name }} + RELEASE: ${{ inputs.version || github.event.release.tag_name }} steps: # Use the maintained manifest on main, including for older releases. - uses: actions/checkout@v4 @@ -45,8 +45,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # The registry entry points at the `sentry` npm package (`sentry mcp`), + # which is released from the CLI workspace with `cli@` tags. + VERSION="${RELEASE#cli@}" [[ "$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 \ + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + gh release view "cli@$VERSION" --repo getsentry/toolkit --json isDraft,isPrerelease \ | jq -e '(.isDraft | not) and (.isPrerelease | not)' jq --arg version "$VERSION" '.version = $version | .packages[0].version = $version' \ server.json > "$RUNNER_TEMP/server.json" @@ -54,10 +58,10 @@ jobs: # Craft publishes the GitHub release before npm. Wait for the exact package. curl --fail --silent --show-error --retry 40 --retry-all-errors \ --retry-delay 15 --retry-max-time 600 --max-time 15 \ - "https://registry.npmjs.org/@sentry%2Fmcp-server/$VERSION" \ + "https://registry.npmjs.org/sentry/$VERSION" \ -o "$RUNNER_TEMP/npm.json" jq -e --arg version "$VERSION" --slurpfile manifest "$RUNNER_TEMP/server.json" \ - '.name == "@sentry/mcp-server" and .version == $version and .mcpName == $manifest[0].name' \ + '.name == "sentry" and .version == $version and .mcpName == $manifest[0].name' \ "$RUNNER_TEMP/npm.json" - name: Check for an existing listing 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..039103a2b 100644 --- a/.github/workflows/merge-jobs.yml +++ b/.github/workflows/merge-jobs.yml @@ -26,7 +26,7 @@ jobs: run: pnpm install - name: Build - run: pnpm --filter '@sentry/mcp-server...' --if-present run build + run: pnpm --filter '@sentry/mcp-core...' --if-present run build - name: Run linter run: pnpm lint @@ -34,6 +34,8 @@ jobs: - name: Run tests run: pnpm test:ci + # @sentry/mcp-server is a deprecation shim for `sentry mcp`; delete this + # step with the package after its final publish. - name: Package mcp-server working-directory: packages/mcp-server run: pnpm pack --pack-destination dist diff --git a/.gitignore b/.gitignore index 7e000454f..d433329c7 100644 --- a/.gitignore +++ b/.gitignore @@ -77,8 +77,6 @@ coverage .env.sentry-build-plugin # Generated files -packages/mcp-server/src/toolDefinitions.json -packages/mcp-server/src/skillDefinitions.json packages/mcp-server-evals/eval-results.json apps/cli-docs/.astro apps/cli-docs/src/content/docs/commands/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 5e913135b..db13aa955 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -53,6 +53,31 @@ "jsx-a11y/no-static-element-interactions": "off" } }, + { + "files": [ + "packages/mcp-core/**/*.{ts,tsx,js,mjs,cjs}", + "packages/toolkit-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}", diff --git a/AGENTS.md b/AGENTS.md index 73ded3708..d5676b853 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 @@ -24,7 +25,7 @@ toolkit/ │ ├── cli/ # Sentry CLI (see packages/cli/AGENTS.md) │ ├── toolkit-core/ # Shared CLI/MCP primitives │ ├── mcp-core/ # Core MCP implementation (private) -│ ├── mcp-server/ # stdio transport (@sentry/mcp-server on npm) +│ ├── mcp-server/ # Deprecated shim that runs `sentry mcp` (delete after final publish) │ ├── mcp-cloudflare/ # Web app + OAuth │ ├── mcp-server-evals/ # AI evaluation tests │ ├── mcp-server-mocks/ # MSW mocks @@ -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 — How `sentry mcp` reuses CLI auth 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` release, registry, and the `@sentry/mcp-server` shim - docs/releases/cloudflare.md — Cloudflare deployment ## Commands diff --git a/README.md b/README.md index 5ab2e9d7a..d82580027 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 is the `sentry mcp` command in the [`sentry` CLI](https://cli.sentry.dev). It uses your CLI login (`sentry auth login`). MCP client configurations written for the retired `@sentry/mcp-server` package keep working: `--access-token` and `SENTRY_ACCESS_TOKEN` still pass a static token, and every other flag is unchanged. + +To use a static token, create a User Auth Token in Sentry with the necessary scopes. As of writing this is: ``` org:read @@ -59,7 +61,8 @@ event:write Launch the transport: ```shell -npx @sentry/mcp-server@latest --access-token=sentry-user-token +npx -y sentry@latest mcp # uses `sentry auth login` +npx -y sentry@latest mcp --access-token=sentry-user-token ``` Need to connect to a self-hosted deployment? Add --host (hostname @@ -72,20 +75,20 @@ default skill set whenever `--host` points at a non-`sentry.io` host. 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 +npx -y sentry@latest mcp --access-token=TOKEN --host=sentry.example.com --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 +npx -y sentry@latest mcp --access-token=TOKEN --host=sentry.example.com --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 +npx -y sentry@latest mcp --access-token=TOKEN --host=sentry.internal:9000 --insecure-http ``` #### Remote with an Explicit Sentry Token @@ -118,7 +121,7 @@ tools with `?skills=inspect,triage` or `?disable-skills=seer`. #### Environment Variables ```shell -SENTRY_ACCESS_TOKEN= # Required: Your Sentry auth token +SENTRY_ACCESS_TOKEN= # Optional with `sentry auth login`: static Sentry auth token # LLM Provider Configuration (required for AI-powered search tools) EMBEDDED_AGENT_PROVIDER= # Required when multiple provider keys are set: 'openai', 'azure-openai', 'anthropic', or 'openrouter' @@ -143,7 +146,7 @@ MCP_DISABLE_SKILLS= # Disable specific skills (comma-separated, e.g. 'p "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server"], + "args": ["-y", "sentry@latest", "mcp"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -166,7 +169,7 @@ self-hosted deployment that does run Seer, opt in with `MCP_SKILLS`: "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server"], + "args": ["-y", "sentry@latest", "mcp"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "SENTRY_HOST": "sentry.example.com", diff --git a/ast-grep-rules/no-stdio-stdout.yml b/ast-grep-rules/no-stdio-stdout.yml index a93c82d48..c9ba12cec 100644 --- a/ast-grep-rules/no-stdio-stdout.yml +++ b/ast-grep-rules/no-stdio-stdout.yml @@ -10,8 +10,7 @@ rule: - pattern: console.debug($$$ARGS) files: - packages/mcp-core/src/**/*.ts - - packages/mcp-server/src/**/*.ts + - packages/cli/src/lib/mcp/**/*.ts ignores: - - packages/mcp-server/src/cli/** - "**/*.test.ts" - "**/*.d.ts" diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index f095c646a..9706c430c 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/ # Deprecated shim that runs `sentry mcp` ├── mcp-cloudflare/ # Cloudflare Workers deployment ├── mcp-server-evals/ # Evaluation test suite ├── mcp-server-mocks/ # Shared mock data @@ -31,6 +33,7 @@ src/ ├── api-client/ # Sentry API client ├── internal/ # Utilities (not exposed) ├── tools/ # Tool implementations +├── transports/ # stdio transport ├── toolDefinitions.ts # Tool schemas and metadata ├── server.ts # buildServer() function ├── errors.ts # Custom error types @@ -52,27 +55,21 @@ catalog changes. Modern `subscriptions/listen` acknowledgments therefore omit **Note:** This package is **not published to npm**. It's a workspace-only package. -### packages/mcp-server - -Published stdio transport package (as `@sentry/mcp-server`). +### packages/cli (`sentry mcp`) -**Structure:** +The local stdio MCP server is the `sentry mcp` command in the Sentry CLI. -``` -src/ -├── cli/ # CLI argument parsing -├── transports/ # stdio transport -└── index.ts # Main entry point -``` +- `src/lib/mcp.ts` resolves credentials from the CLI session (or a static + `--access-token` / `SENTRY_ACCESS_TOKEN`) and lazily loads the host, so normal + CLI startup never loads mcp-core or Zod. +- `src/lib/mcp/` parses server flags and environment variables, configures the + embedded agent provider and telemetry, and starts mcp-core's stdio transport. -**Key responsibilities:** +### packages/mcp-server (deprecated) -- CLI entry point for the MCP server -- stdio transport implementation -- Bundles all mcp-core code at build time -- Published to npm for end users - -**Build:** Uses tsdown to bundle mcp-core code, resulting in a self-contained package. +A deprecation shim for one last `@sentry/mcp-server` release. Its `sentry-mcp` +bin prints a notice and runs `sentry mcp` with the same arguments. Delete it after +the final publish; see [stdio release](../releases/stdio.md). ### packages/mcp-cloudflare @@ -114,6 +111,32 @@ Interactive CLI for testing the MCP server with an AI agent. - Stdio transport for local testing - Clean terminal output with tool call visualization +## Package Dependency Direction + +Workspace dependencies point one way: + +``` +sentry (packages/cli) ──> @sentry/mcp-core ──> @sentry/toolkit-core +@sentry/mcp-cloudflare ──> @sentry/mcp-core +``` + +mcp-core and toolkit-core never import the CLI, and toolkit-core never imports +mcp-core. When an MCP tool needs a capability only the CLI host has, such as +the SQLite cache, DSN detection from local files, or file uploads, define an +interface in mcp-core or toolkit-core and let the CLI inject an implementation +(the way `sentry mcp` passes the access token and the refresh callback through +`ServerContext`). The hosted server can then supply its own implementation or +leave the capability out. + +Two checks enforce this in `pnpm run lint`: + +- oxlint `no-restricted-imports` (`.oxlintrc.json`) rejects `sentry`, + `sentry/*`, and relative CLI paths inside `packages/mcp-core` and + `packages/toolkit-core`. +- `scripts/check-workspace-deps.mjs` (`pnpm run lint:deps`) reads every + workspace `package.json` and fails on a dependency cycle or a forbidden edge, + such as mcp-core reaching `sentry` or toolkit-core reaching mcp-core. + ## Key Architectural Decisions ### 1. Protocol Implementation diff --git a/docs/contributing/adding-tools.md b/docs/contributing/adding-tools.md index da9f780cd..aa76fff4a 100644 --- a/docs/contributing/adding-tools.md +++ b/docs/contributing/adding-tools.md @@ -253,7 +253,7 @@ describe("your_tool_name", () => { **After changing output, update snapshots:** ```bash -cd packages/mcp-server +cd packages/mcp-core pnpm vitest --run -u ``` diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index e0db72ea5..beaba7c3b 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. @@ -311,7 +311,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/integrations/ide-instructions-refactor.md b/docs/integrations/ide-instructions-refactor.md index 2bab2d2e2..9e6174787 100644 --- a/docs/integrations/ide-instructions-refactor.md +++ b/docs/integrations/ide-instructions-refactor.md @@ -181,7 +181,7 @@ export default function InstallTabs({ ```typescript
    -
  1. Run npx @sentry/mcp-server
  2. +
  3. Run npx -y sentry@latest mcp
  4. {/* ... inline instructions */}
diff --git a/docs/mcp/README.md b/docs/mcp/README.md index a57b1b313..169530a7a 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/`, `sentry mcp` in `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..2f1b58a2b 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 -y sentry@latest mcp --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 -y sentry@latest mcp --agent-provider=openai --access-token=... ``` #### Method 3: MCP Configuration File @@ -95,7 +95,7 @@ In your MCP settings (e.g., Claude Desktop config): "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server"], + "args": ["-y", "sentry@latest", "mcp"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -137,7 +137,7 @@ Always set `EMBEDDED_AGENT_PROVIDER` when multiple API keys might be present: "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server"], + "args": ["-y", "sentry@latest", "mcp"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -194,7 +194,7 @@ For custom OpenAI-compatible endpoints or Anthropic proxies: ```bash export EMBEDDED_AGENT_PROVIDER=openai export OPENAI_API_KEY=sk-... -npx @sentry/mcp-server --openai-base-url=https://custom.openai.example.com +npx -y sentry@latest mcp --openai-base-url=https://custom.openai.example.com ``` For Azure OpenAI or Azure-compatible deployment proxies, use the dedicated @@ -206,10 +206,10 @@ export OPENAI_API_KEY=sk-... export OPENAI_API_VERSION=2024-02-15-preview # Azure v1 endpoint -> Responses API -npx @sentry/mcp-server --openai-base-url=https://example.openai.azure.com/openai/v1/ +npx -y sentry@latest mcp --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 -y sentry@latest mcp --openai-base-url=https://example.openai.azure.com/openai/deployments/my-assistant ``` ### Model Selection @@ -233,7 +233,7 @@ export OPENROUTER_REASONING_EFFORT=high Check which provider is being used: ```bash -npx @sentry/mcp-server --access-token=... 2>&1 | grep "Using" +npx -y sentry@latest mcp --access-token=... 2>&1 | grep "Using" ``` Expected output: @@ -290,7 +290,7 @@ export ANTHROPIC_API_KEY=sk-ant-test export OPENAI_API_KEY=sk-test export EMBEDDED_AGENT_PROVIDER=openai -npx @sentry/mcp-server --access-token=... +npx -y sentry@latest mcp --access-token=... ``` Should output: diff --git a/docs/operations/logging.md b/docs/operations/logging.md index e1ce17e71..8d97bcbc8 100644 --- a/docs/operations/logging.md +++ b/docs/operations/logging.md @@ -16,7 +16,7 @@ Log levels are controlled by: **Important**: We use a custom LogTape sink that calls `Sentry.logger.*` methods to send logs to Sentry's Logs product. The `@logtape/sentry` package uses `captureException/captureMessage` which creates Issues instead of Logs. -Implementation: `packages/mcp-server/src/telem/logging.ts` +Implementation: `packages/mcp-core/src/telem/logging.ts` ## Stdio Transport Invariant: Logs MUST Go to Stderr @@ -37,7 +37,7 @@ Regression history: #922 — LogTape's default level map sent `info`/`debug` rec ### logInfo() - Routine telemetry ```typescript -import { logInfo } from "@sentry/mcp-server/telem/logging"; +import { logInfo } from "@sentry/mcp-core/telem/logging"; logInfo("MCP server started", { loggerScope: ["server", "lifecycle"], @@ -48,7 +48,7 @@ logInfo("MCP server started", { ### logWarn() - Operational warnings ```typescript -import { logWarn } from "@sentry/mcp-server/telem/logging"; +import { logWarn } from "@sentry/mcp-core/telem/logging"; logWarn("Rate limit approaching", { loggerScope: ["api", "rate-limit"], @@ -59,7 +59,7 @@ logWarn("Rate limit approaching", { ### logError() - Operational errors (no Sentry issue) ```typescript -import { logError } from "@sentry/mcp-server/telem/logging"; +import { logError } from "@sentry/mcp-core/telem/logging"; logError(error, { loggerScope: ["tools", "fetch-trace"], @@ -70,7 +70,7 @@ logError(error, { ### logIssue() - System errors (creates Sentry issue) ```typescript -import { logIssue } from "@sentry/mcp-server/telem/logging"; +import { logIssue } from "@sentry/mcp-core/telem/logging"; const eventId = logIssue(error, { loggerScope: ["oauth", "token-refresh"], @@ -156,7 +156,7 @@ app.use(createRequestLogger(["cloudflare", "http"])); ## References -- Implementation: `packages/mcp-server/src/telem/logging.ts` +- Implementation: `packages/mcp-core/src/telem/logging.ts` - Error handling patterns: [Error Handling](../contributing/error-handling.md) - Monitoring and tracing: [Monitoring](monitoring.md) - LogTape docs: https://logtape.org/ diff --git a/docs/operations/monitoring.md b/docs/operations/monitoring.md index 31bd2c1fc..fe9c08575 100644 --- a/docs/operations/monitoring.md +++ b/docs/operations/monitoring.md @@ -15,7 +15,7 @@ Different Sentry SDKs for different environments: ### Error Logging -See `logIssue` in `packages/mcp-server/src/telem/logging.ts` (documented in [Logging Reference](logging.md)) for the canonical way to create an Issue and structured log entry. +See `logIssue` in `packages/mcp-core/src/telem/logging.ts` (documented in [Logging Reference](logging.md)) for the canonical way to create an Issue and structured log entry. For event schema failures, see [Event Validation Diagnostics](../../TELEMETRY.md#event-validation-diagnostics) for the `contextType` diagnostic and its privacy constraints. @@ -365,7 +365,7 @@ NODE_ENV=production ## References -- Core logging: `packages/mcp-server/src/logging.ts` +- Core logging: `packages/mcp-core/src/logging.ts` - Worker config: `packages/mcp-cloudflare/src/server/sentry.config.ts` -- Tracing helpers: `packages/mcp-server/src/tracing.ts` +- Tracing helpers: `packages/mcp-core/src/tracing.ts` - Sentry docs: https://docs.sentry.io/ diff --git a/docs/operations/security.md b/docs/operations/security.md index 670ce6120..bebaee54d 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -229,7 +229,7 @@ validateRegionUrl("https://eu.sentry.io", "sentry.io"); // ❌ Not in allowlist validateRegionUrl("https://sub.mycompany.com", "mycompany.com"); // ❌ Not base host or allowlist ``` -Implementation: `packages/mcp-server/src/internal/tool-helpers/validate-region-url.ts` +Implementation: `packages/mcp-core/src/internal/tool-helpers/validate-region-url.ts` ### Prompt Injection Protection diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index 7deaa2c74..3becdf3d9 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -1,141 +1,55 @@ # Stdio Authentication -How the stdio transport authenticates with Sentry, including the device code flow, token caching, and the relationship between the bundled client ID and the Cloudflare OAuth app. - -## Authentication Methods - -The stdio transport supports two authentication methods, in order of precedence: - -1. **Explicit token** — `--access-token` flag or `SENTRY_ACCESS_TOKEN` env var. Works for all Sentry hosts. This is the only method available for self-hosted instances. - For self-hosted deployments that only expose plain HTTP, pair `--access-token` with `--host` and `--insecure-http`. - -2. **Device code flow** — OAuth Device Authorization Grant (RFC 8628). Only available for sentry.io (including regional subdomains like `us.sentry.io`). Requires an interactive terminal (TTY). Falls back to cached tokens in non-interactive contexts. - -## Device Code Flow - -When no token is provided and the host is sentry.io, the CLI initiates the device code flow: - -``` -CLI Sentry (sentry.io) - │ │ - │ POST /oauth/device/code/ │ - │ (client_id, scope) │ - │──────────────────────────────>│ - │ │ - │ device_code, user_code, │ - │ verification_uri_complete │ - │<──────────────────────────────│ - │ │ - │ Display code to user │ - │ Open browser to verify URL │ - │ │ - │ POST /oauth/token/ │ (poll every N seconds) - │ (device_code, client_id) │ - │──────────────────────────────>│ - │ │ - │ authorization_pending │ (user hasn't authorized yet) - │<──────────────────────────────│ - │ │ - │ ... poll again ... │ - │ │ - │ access_token, refresh_token │ (user authorized) - │<──────────────────────────────│ - │ │ - │ Cache token to disk │ - │ Start MCP server │ -``` - -### OAuth Endpoints - -All OAuth requests target `sentry.io` directly (via `OAUTH_HOST` constant), regardless of regional subdomain configuration. The device code and token endpoints live on the main domain only. - -### Scopes - -The device code flow requests the same scopes as the Cloudflare OAuth app: - -``` -org:read project:write team:write event:write alerts:write -``` - -These are defined once in `packages/mcp-core/src/scopes.ts` and shared by both transports. - -## Client ID - -A bundled `DEFAULT_SENTRY_CLIENT_ID` ships in `packages/mcp-server/src/auth/constants.ts`. This is a public OAuth application registered on sentry.io specifically for the stdio device code flow. It has no client secret — device code flow doesn't use one. - -The Cloudflare deployment uses a separate OAuth application (configured via `SENTRY_CLIENT_ID` + `SENTRY_CLIENT_SECRET` env vars) because it uses the authorization code grant which requires a secret. - -Both transports use the `SENTRY_CLIENT_ID` env var name for overriding, but they're separate deployments with separate values: - -| Transport | Default client ID | Grant type | Client secret | -| ---------- | ------------------------------ | ---------------------- | ------------------------------ | -| stdio | Bundled in `auth/constants.ts` | Device code (RFC 8628) | None (public client) | -| Cloudflare | `SENTRY_CLIENT_ID` env var | Authorization code | `SENTRY_CLIENT_SECRET` env var | - -## Token Cache - -Tokens are cached at `~/.sentry/mcp.json` to avoid re-authentication on every server start. - -### File Format - -```json -{ - "sentry.io:client-id-here": { - "access_token": "...", - "refresh_token": "...", - "expires_at": "2026-04-25T...", - "sentry_host": "sentry.io", - "client_id": "...", - "user_email": "user@example.com", - "scope": "org:read project:write team:write event:write alerts:write" - } -} -``` - -Cache entries are keyed by `{host}:{clientId}` so different hosts or client IDs don't collide. - -### Security - -- File permissions: `0o600` (owner read/write only) -- Directory permissions: `0o700` -- Writes are atomic (temp file + rename) -- Tokens are treated as expired 5 minutes before actual expiry - -### Override - -Set `SENTRY_MCP_AUTH_CACHE` to override the cache file path (primarily for testing). - -## Non-Interactive Contexts - -When stderr is not a TTY (CI, piped stdio, MCP inspector), the device code flow is not started — it would hang waiting for a human. Instead: - -1. If a cached token exists, it's used silently. -2. If no cached token exists, the process exits with an error directing the user to run `sentry-mcp auth login` interactively first. - -## Auth CLI Commands - -The CLI exposes auth management as subcommands: - -```bash -sentry-mcp auth login # Force device code flow (always re-authenticates) -sentry-mcp auth logout # Clear cached token -sentry-mcp auth status # Show current auth state -``` - -These commands support `--host` and `--url` flags with the same precedence as the main server: `--url` beats `--host`, CLI beats env vars. +How the local stdio MCP server (`sentry mcp`) authenticates with Sentry. + +`sentry mcp` has no auth flow of its own. It reuses the Sentry CLI's +credentials, so `sentry auth login`, `sentry auth logout`, and +`sentry auth status` manage the MCP server's credentials too. + +## Credential Sources + +In order of precedence: + +1. **Static token** — `--access-token` flag, then `SENTRY_ACCESS_TOKEN`. Kept + for MCP client configurations written for the retired `@sentry/mcp-server` + package. The token is used as given for the life of the server and is never + refreshed. Org auth tokens (`sntrys_`) record the Sentry URL they were + issued for; `sentry mcp` refuses to send one to a different host + (`HostScopeError`). `SENTRY_ACCESS_TOKEN` only affects `sentry mcp`; other + CLI commands ignore it. +2. **CLI credentials** — the stored `sentry auth login` session, or + `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` (see the CLI's + `SENTRY_FORCE_ENV_TOKEN` rules). These go through the CLI's + `refreshToken()`, which enforces the same host scoping as every CLI command. + When a tool call gets a 401 from Sentry, the server forces a refresh and + later calls use the new token, so a long-running server survives token + rotation without a restart. + +When no credential is available and stdin is a TTY, `sentry mcp` starts the +CLI's interactive login before serving. MCP clients launch the server over +pipes, so in that case it exits with an error that tells the user to run +`sentry auth login`. + +For self-hosted Sentry, set the host with `--host`/`--url` (or the CLI's +`SENTRY_HOST`/`SENTRY_URL`). Pair `--host` with `--insecure-http` for instances +that only expose plain HTTP. + +## Migration From `@sentry/mcp-server` + +The standalone package's device code flow, bundled client ID, and token cache +(`~/.sentry/mcp.json`, `SENTRY_MCP_AUTH_CACHE`) are gone. `sentry mcp` does not +read the old cache. Users who relied on it run `sentry auth login` once, or keep +passing `--access-token` / `SENTRY_ACCESS_TOKEN`. + +The final `@sentry/mcp-server` release is a shim that maps +`sentry-mcp auth login|logout|status` to `sentry auth login|logout|status`. ## Key Files -| File | Purpose | -| -------------------------------------------------- | --------------------------------------------------------- | -| `packages/mcp-server/src/auth/constants.ts` | Bundled client ID, OAuth endpoints, scopes | -| `packages/mcp-server/src/auth/device-code-flow.ts` | RFC 8628 implementation (request, poll, browser open) | -| `packages/mcp-server/src/auth/token-cache.ts` | Persistent token storage at `~/.sentry/mcp.json` | -| `packages/mcp-server/src/auth/resolve-token.ts` | Orchestration: cache check → TTY check → device code flow | -| `packages/mcp-server/src/auth/types.ts` | Zod schemas for API responses, `CachedToken` type | -| `packages/mcp-server/src/cli/commands/auth.ts` | `auth login/logout/status` subcommands | -| `packages/mcp-core/src/scopes.ts` | Shared OAuth scope definitions | - -## Token Refresh - -Not implemented. Sentry access tokens last 30 days. When a cached token expires, it's cleared and a new device code flow is triggered. This matches the Cloudflare transport's approach (see `packages/mcp-cloudflare/src/server/oauth/helpers.ts`). +| File | Purpose | +| ------------------------------------------- | -------------------------------------------------------------- | +| `packages/cli/src/lib/mcp.ts` | Credential resolution, static-token host check, refresh-on-401 | +| `packages/cli/src/lib/mcp/server.ts` | Flag/env parsing, telemetry, and stdio startup | +| `packages/cli/src/lib/db/auth.ts` | CLI token storage and `refreshToken()` | +| `packages/cli/src/lib/token-host.ts` | Host-scoping checks | +| `packages/mcp-core/src/transports/stdio.ts` | stdio transport and per-request auth context | diff --git a/docs/releases/cloudflare.md b/docs/releases/cloudflare.md index 920a3e4eb..f97984525 100644 --- a/docs/releases/cloudflare.md +++ b/docs/releases/cloudflare.md @@ -75,7 +75,7 @@ The MCP handler uses a stateless architecture with closure-captured context: ```typescript import { experimental_createMcpHandler as createMcpHandler } from "agents/mcp"; -import { buildServer } from "@sentry/mcp-server/server"; +import { buildServer } from "@sentry/mcp-core/server"; const mcpHandler: ExportedHandler = { async fetch( diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index ee2889151..87747db3b 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -1,105 +1,58 @@ # 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: +The stdio MCP server is the `sentry mcp` command in the `sentry` npm package +(`packages/cli`). It ships with every CLI release (the `packages/cli` Craft +workspace in `.craft.yml`); there is no separate MCP package to version or +publish. -- Claude Desktop -- Cursor IDE -- VS Code with MCP extension -- Other MCP clients supporting stdio transport +`sentry mcp` bundles `@sentry/mcp-core` (tools, server, and the stdio +transport) and uses the CLI's own authentication: -## Package Structure +- `sentry auth login` session (refreshed automatically, including after an + upstream 401) +- `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`, as for every CLI command +- `--access-token` / `SENTRY_ACCESS_TOKEN`: a static token for `sentry mcp` + only, kept for configurations written for the retired `@sentry/mcp-server` + package. It is never refreshed, and org auth tokens (`sntrys_`) are still + scoped to the host they were issued for. -Published package includes: +## Retired `@sentry/mcp-server` package -- Compiled TypeScript (`dist/`) -- stdio transport implementation -- Type definitions -- Tool definitions +`packages/mcp-server` is a deprecation shim kept for one last release. Its +`sentry-mcp` bin prints a one-line stderr notice and runs `sentry mcp` with the +same arguments (`auth login|logout|status` map to `sentry auth ...`). It depends +on `sentry` (`>=0.48.0`, the first release with full flag parity), so publish +it only after that CLI release is on npm. -## Release Process +Remaining maintainer steps: -### 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 -``` +1. Release `@sentry/mcp-server` once more through the `packages/mcp-server` + Craft workspace (`release/mcp` branches). +2. Deprecate it on npm with `npm deprecate @sentry/mcp-server ""`, + pointing users at `npx -y sentry mcp`. +3. Delete `packages/mcp-server`, its Craft workspace, and the pack step in + `.github/workflows/merge-jobs.yml`. ## 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 (`cli@X.Y.Z` tags) using GitHub Actions OIDC; no registry secret is +needed. It reads the manifest from `main` and sets both versions from the +release, so there is no extra version bump to maintain. The listing points at +the `sentry` npm package with `mcp` as a package argument, plus the hosted +endpoint. npm verifies ownership through `mcpName` in +`packages/cli/package.json`. Craft publishes GitHub before npm. The workflow waits for the npm package before publishing the listing. Check this Actions run separately from the Craft release. -To retry or publish an existing release after merging the workflow: +To retry or publish an existing release: ```bash -gh workflow run mcp-registry.yml --repo getsentry/sentry-mcp --ref main -f version=0.40.0 +gh workflow run mcp-registry.yml --repo getsentry/toolkit --ref main -f version=0.48.0 ``` An identical active listing is safe to rerun. Different metadata for an existing @@ -108,127 +61,35 @@ changes with the next release. Registry failures do not undo the npm release. ## User Installation -Users install via npx in their MCP client configuration: - -### Claude Desktop +Users run the server through npx in their MCP client configuration: ```json { "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "@sentry/mcp-server"], - "env": { - "SENTRY_ACCESS_TOKEN": "sntrys_...", - "SENTRY_HOST": "sentry.io" - } + "args": ["-y", "sentry@latest", "mcp"] } } } ``` -Config location: - -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -### Cursor IDE +Without a `sentry auth login` session, add `"env": { "SENTRY_ACCESS_TOKEN": +"sntryu_..." }`. Pin a version with `sentry@0.48.0`. -Add to `.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "sentry": { - "command": "npx", - "args": ["-y", "@sentry/mcp-server"], - "env": { - "SENTRY_ACCESS_TOKEN": "sntrys_...", - "SENTRY_HOST": "sentry.io" - } - } - } -} -``` - -## Environment Variables - -Required: - -- `SENTRY_ACCESS_TOKEN` - Sentry API access token -- `SENTRY_HOST` - Sentry instance hostname (default: `sentry.io`) - -Optional: - -- `SENTRY_ORG` - Default organization slug -- `SENTRY_PROJECT` - Default project slug -- `MCP_DISABLE_SKILLS` - Disable specific skills, comma-separated (e.g. `seer`) - -## Version Pinning - -Users can pin to specific versions: - -```json -{ - "args": ["-y", "@sentry/mcp-server@1.2.3"] -} -``` - -## Testing Releases - -### Local Testing Before Publishing - -Test the built package locally: +## Testing Before a Release ```bash -cd packages/mcp-server -npm pack -# Creates sentry-mcp-server-1.2.3.tgz +# Run the server from source +pnpm --silent --dir packages/cli run cli mcp --help -# 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: - -```bash -npm publish --tag beta +# Exercise it with the test client +pnpm -w run cli --transport stdio "who am I?" ``` -Users install with: - -```json -{ - "args": ["-y", "@sentry/mcp-server@beta"] -} -``` - -## Troubleshooting - -### Package Not Found - -- Verify package name: `@sentry/mcp-server` (with scope) -- Check npm registry: `npm view @sentry/mcp-server` - -### Version Mismatch - -- Users may have cached version: `npx clear-npx-cache` -- Recommend version pinning for stability - -### Build Failures - -- Ensure `pnpm run build` succeeds before publishing -- Check TypeScript compilation errors -- Verify all dependencies are listed in package.json - ## References -- Package config: `packages/mcp-server/package.json` -- stdio transport: `packages/mcp-server/src/transports/stdio.ts` -- Build script: `packages/mcp-server/scripts/build.ts` -- npm publishing docs: https://docs.npmjs.com/cli/publish +- Command entry: `packages/cli/src/lib/mcp.ts` +- Host (flags, env, telemetry): `packages/cli/src/lib/mcp/` +- stdio transport: `packages/mcp-core/src/transports/stdio.ts` +- Deprecation shim: `packages/mcp-server/` diff --git a/docs/specs/embedded-agent-openai-routing.md b/docs/specs/embedded-agent-openai-routing.md index e247ac21d..ab32ce1cc 100644 --- a/docs/specs/embedded-agent-openai-routing.md +++ b/docs/specs/embedded-agent-openai-routing.md @@ -124,10 +124,9 @@ Update: - `packages/mcp-core/src/internal/agents/types.ts` - `packages/mcp-core/src/internal/agents/provider-factory.ts` -- `packages/mcp-server/src/cli/types.ts` -- `packages/mcp-server/src/cli/parse.ts` -- `packages/mcp-server/src/cli/resolve.ts` -- `packages/mcp-server/src/cli/usage.ts` +- `packages/cli/src/lib/mcp/args.ts` +- `packages/cli/src/lib/mcp/config.ts` +- `packages/cli/src/lib/mcp/usage.ts` Add: @@ -156,7 +155,7 @@ The key requirement is conceptual separation, not the specific SDK choice. ### Direct OpenAI ```bash -npx @sentry/mcp-server --access-token=TOKEN +npx -y sentry@latest mcp --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 -y sentry@latest mcp \ --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 -y sentry@latest mcp \ --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 -y sentry@latest mcp \ --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..8f938cebd 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 the `sentry mcp` command in the Sentry CLI (`packages/cli`); it bundles `@sentry/mcp-core`. **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,36 @@ 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 -``` - -### 2. Build the Package - -```bash -# Build all packages (includes mcp-server) -pnpm -w run build - -# Or build just mcp-server -cd packages/mcp-server -pnpm build -``` - -The build process: - -1. Generates tool definitions (`pnpm run generate-definitions`) -2. Compiles TypeScript to JavaScript -3. Creates both ESM and CJS outputs -4. Generates type declarations -5. Makes the CLI executable - -**Output location:** `packages/mcp-server/dist/` - -### 3. Verify Build - -```bash -# Check the built executable exists -ls -la packages/mcp-server/dist/index.js - -# Test the CLI help -node packages/mcp-server/dist/index.js --help +pnpm -w run build # builds mcp-core, which `sentry mcp` imports ``` -## Running Stdio Locally - -### Option 1: Using pnpm start (Development) - -Best for active development with TypeScript sources: +### 2. Run From Source ```bash -cd packages/mcp-server -pnpm start --access-token=YOUR_TOKEN +# From the repo root. Uses your CLI login when no token is passed. +pnpm --silent --dir packages/cli run cli mcp --help +pnpm --silent --dir packages/cli run cli mcp +pnpm --silent --dir packages/cli run cli mcp --access-token=YOUR_TOKEN ``` -This uses `tsx` to run TypeScript directly without building. - -### Option 2: Using the Built Package (Production-like) +`--silent` keeps pnpm's banner off stdout, which carries JSON-RPC frames. -Test the actual build output: +### 3. 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 -y sentry@latest mcp +npx -y sentry@latest mcp --access-token=YOUR_TOKEN ``` -### 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 npm bundle, build it with `pnpm --filter sentry run bundle` +(unset `SENTRY_AUTH_TOKEN` first so the build does not upload sourcemaps), then +run `node packages/cli/dist/bin.cjs mcp`. ## Testing with MCP Inspector @@ -130,8 +79,8 @@ The MCP Inspector is the best tool for interactive testing of the stdio transpor ### 1. Start the Inspector ```bash -# From repo root -pnpm inspector +# From repo root; launches `sentry mcp` from source +pnpm inspector:stdio ``` This opens the MCP Inspector at `http://localhost:6274` @@ -150,21 +99,12 @@ This opens the MCP Inspector at `http://localhost:6274` { "command": "pnpm", "args": [ + "--silent", "--dir", - "/absolute/path/to/sentry-mcp/packages/mcp-server", - "start", - "--access-token=YOUR_TOKEN" - ] -} -``` - -**For built package:** - -```json -{ - "command": "node", - "args": [ - "/absolute/path/to/sentry-mcp/packages/mcp-server/dist/index.js", + "/absolute/path/to/toolkit/packages/cli", + "run", + "cli", + "mcp", "--access-token=YOUR_TOKEN" ] } @@ -176,7 +116,9 @@ This opens the MCP Inspector at `http://localhost:6274` { "command": "npx", "args": [ - "@sentry/mcp-server@latest", + "-y", + "sentry@latest", + "mcp", "--access-token=YOUR_TOKEN", "--host=sentry.example.com" ] @@ -189,7 +131,9 @@ This opens the MCP Inspector at `http://localhost:6274` { "command": "npx", "args": [ - "@sentry/mcp-server@latest", + "-y", + "sentry@latest", + "mcp", "--access-token=YOUR_TOKEN", "--host=sentry.internal:9000", "--insecure-http" @@ -334,7 +278,7 @@ Create or edit `.cursor/config.json`: "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server@latest", "--access-token=YOUR_TOKEN"] + "args": ["-y", "sentry@latest", "mcp", "--access-token=YOUR_TOKEN"] } } } @@ -355,7 +299,7 @@ Update config: "mcpServers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server@latest"], + "args": ["-y", "sentry@latest", "mcp"], "env": { "SENTRY_ACCESS_TOKEN": "${SENTRY_ACCESS_TOKEN}" } @@ -387,7 +331,7 @@ Add to `.vscode/settings.json`: "mcp.servers": { "sentry": { "command": "npx", - "args": ["@sentry/mcp-server@latest", "--access-token=YOUR_TOKEN"] + "args": ["-y", "sentry@latest", "mcp", "--access-token=YOUR_TOKEN"] } } } @@ -400,26 +344,31 @@ Add to `.vscode/settings.json`: ## Configuration Options +Run `pnpm --silent --dir packages/cli run cli mcp --help` for the full list. + ### Command-Line Flags ```bash -# Basic usage ---access-token=TOKEN # Sentry access token (required) +# Authentication (optional with a CLI login) +--access-token=TOKEN # Static token; never refreshed # Host configuration --host=sentry.example.com # Self-hosted Sentry (hostname only) +--url=https://sentry.example.com # Self-hosted Sentry (full URL) +--insecure-http # Allow http:// for --host # Skills management --skills=inspect,docs,triage # Limit to specific skills (default: all available) +--disable-skills=seer # Remove skills from the default set # AI features (optional) +--agent-provider=openai # openai, azure-openai, or anthropic --openai-base-url=URL # Custom OpenAI endpoint # Sentry reporting --sentry-dsn=DSN # Custom Sentry DSN for telemetry --sentry-dsn= # Disable telemetry - # Help --help # Show all options --version # Show version @@ -428,14 +377,16 @@ Add to `.vscode/settings.json`: ### Environment Variables ```bash -# Authentication -SENTRY_ACCESS_TOKEN=your-token +# Authentication (optional with a CLI login) +SENTRY_ACCESS_TOKEN=your-token # Same as --access-token +SENTRY_AUTH_TOKEN=your-token # Regular CLI auth, refreshed like any CLI command # Host (self-hosted only) SENTRY_HOST=sentry.example.com # Skills MCP_SKILLS=inspect,docs,triage # Limit to specific skills +MCP_DISABLE_SKILLS=seer # AI features OPENAI_API_KEY=your-key # For AI-powered search tools like search_errors/search_traces/search_issues @@ -455,22 +406,14 @@ SENTRY_DSN=your-dsn pnpm -w run build && pnpm -w run test # Quick smoke test -cd packages/mcp-server -pnpm start --access-token=TOKEN & -sleep 2 -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | pnpm start --access-token=TOKEN +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \ + pnpm --silent --dir packages/cli run cli mcp --access-token=TOKEN ``` ### 2. Test Against Self-Hosted Sentry ```bash -# Development server -pnpm start \ - --access-token=TOKEN \ - --host=sentry.local.dev - -# Built package -node dist/index.js \ +pnpm --silent --dir packages/cli run cli mcp \ --access-token=TOKEN \ --host=sentry.local.dev ``` @@ -478,70 +421,36 @@ node dist/index.js \ ### 3. Test Skills ```bash -# Test with all skills (default) -pnpm start --access-token=TOKEN - # Test with specific skills only -pnpm start --access-token=TOKEN --skills=inspect,docs +pnpm --silent --dir packages/cli run cli mcp --skills=inspect,docs # Test read-only skills -pnpm start --access-token=TOKEN --skills=inspect,seer,docs +pnpm --silent --dir packages/cli run cli mcp --skills=inspect,seer,docs ``` ### 4. Test AI-Powered Tools ```bash -# With OpenAI API key -OPENAI_API_KEY=your-key pnpm start --access-token=TOKEN +OPENAI_API_KEY=your-key pnpm --silent --dir packages/cli run cli mcp -# Test the dataset search tools and search_issues work # In MCP Inspector: # - Call search_errors(query="errors in production") # - Call search_issues(query="unresolved crashes") ``` -### "Command not found: npx @sentry/mcp-server" - -**Cause:** Package not published or not in npm registry. +## Troubleshooting -**Solution:** - -```bash -# Use local build instead -cd packages/mcp-server -pnpm build -node dist/index.js --access-token=TOKEN -``` +### "Not authenticated" -### "Missing required parameter: access-token" +**Cause:** No CLI login and no static token. -**Cause:** No authentication provided. - -**Solution:** - -```bash -# Option 1: CLI flag -pnpm start --access-token=YOUR_TOKEN - -# Option 2: Environment variable -export SENTRY_ACCESS_TOKEN=YOUR_TOKEN -pnpm start -``` +**Solution:** Run `sentry auth login` (`pnpm --filter sentry run cli auth login` +from source), or pass `--access-token` / `SENTRY_ACCESS_TOKEN`. MCP clients do +not give the server a TTY, so log in from a terminal first. ### "AI-powered search tools unavailable" -**Cause:** Missing `OPENAI_API_KEY`. - -**Solution:** - -```bash -# Set the API key -export OPENAI_API_KEY=your-openai-key -pnpm start --access-token=TOKEN - -# Or disable warning by acknowledging it -# (All other tools will work normally) -``` +**Cause:** Missing `OPENAI_API_KEY`. All other tools work normally. ### "Cannot connect to Sentry API" @@ -551,164 +460,52 @@ pnpm start --access-token=TOKEN 2. Wrong host configuration 3. Network issues -**Solution:** - ```bash # Test token manually -curl -H "Authorization: Bearer YOUR_TOKEN" \ - https://sentry.io/api/0/ - -# For self-hosted, verify host -curl -H "Authorization: Bearer YOUR_TOKEN" \ - https://your-sentry-host.com/api/0/ +curl -H "Authorization: Bearer YOUR_TOKEN" https://sentry.io/api/0/ -# Check logs for detailed error -pnpm start --access-token=TOKEN 2>&1 | tee debug.log +# Check stderr for detailed errors +pnpm --silent --dir packages/cli run cli mcp --access-token=TOKEN 2>debug.log ``` ### "MCP Inspector can't connect to stdio server" -**Causes:** - -1. Incorrect command path -2. Missing dependencies -3. Process exits immediately - -**Solution:** - -```bash -# Test command manually first -pnpm start --access-token=TOKEN - -# If it works, use absolute paths in Inspector -which node # Get absolute path to node -pwd # Get absolute path to project - -# Use absolute paths in Inspector config -{ - "command": "/usr/local/bin/node", - "args": ["/absolute/path/to/sentry-mcp/packages/mcp-server/dist/index.js"] -} -``` - -### "Permission denied" errors - -**Cause:** Built executable not marked as executable. - -**Solution:** - -```bash -chmod +x packages/mcp-server/dist/index.js -``` - -### "Module not found" errors after build - -**Cause:** Missing dependencies in built output. - -**Solution:** - -```bash -# Clean and rebuild -cd packages/mcp-server -rm -rf dist -pnpm build - -# Verify dependencies are bundled -ls -lh dist/ -``` +Run the command manually first. If it works, use absolute paths in the +Inspector config, and keep `--silent` so pnpm prints nothing on stdout. ## Quality Checks Before committing changes that affect stdio: ```bash -# 1. Build successfully pnpm -w run build - -# 2. Type check pnpm -w run tsc - -# 3. Lint pnpm -w run lint - -# 4. Unit tests pass -cd packages/mcp-server -pnpm test - -# 5. Smoke test stdio works +pnpm --dir packages/cli exec vitest run test/lib/mcp test/lib/mcp-start.test.ts echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \ - node dist/index.js --access-token=TOKEN + pnpm --silent --dir packages/cli run cli mcp --access-token=TOKEN ``` ## Comparing with Remote -To verify stdio behaves the same as remote: - -**Test with both transports:** - ```bash # 1. Test stdio locally -pnpm start --access-token=TOKEN -# Use MCP Inspector to test tools +pnpm -w run cli --transport stdio "who am I" # 2. Test remote pnpm -w run cli --mcp-host=https://mcp.sentry.dev "who am I" - -# 3. Compare results -# Both should return same data, same tool list ``` **Key differences to expect:** -- **Authentication:** Stdio uses access tokens, remote uses OAuth +- **Authentication:** Stdio uses CLI credentials or a static token, remote uses OAuth - **Constraints:** Remote supports URL-based org/project constraints - **Tools:** Both should have same tool count and functionality -- **Performance:** Stdio has no network overhead (faster) - -## Advanced Testing - -### Testing Custom Builds - -```bash -# Pack the local build -cd packages/mcp-server -pnpm pack - -# Install globally for testing -npm install -g ./sentry-mcp-server-*.tgz - -# Test as end-user would -sentry-mcp --access-token=TOKEN -``` - -### Testing with Different Node Versions - -```bash -# Using nvm -nvm install 20 -nvm use 20 -pnpm start --access-token=TOKEN - -nvm install 22 -nvm use 22 -pnpm start --access-token=TOKEN -``` - -### Performance Testing - -```bash -# Time tool execution -time node dist/index.js --access-token=TOKEN < { try { await startMcpServer(mcpArgs); } catch (mcpError) { - process.stderr.write(`${formatError(mcpError)}\n`); + process.stderr.write(`Error: ${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 diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index a2462aa99..52e9c83a4 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -66,6 +66,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ selfHosted: true, devGuide: "Force env token to take priority over stored OAuth token", }, + { + name: "SENTRY_ACCESS_TOKEN", + description: + "Static auth token for `sentry mcp`, kept for MCP client configurations written for the retired `@sentry/mcp-server` package (same as `sentry mcp --access-token`). It replaces the CLI session for the MCP server only and is never refreshed. Other commands ignore it.", + example: "sntryu_YOUR_TOKEN_HERE", + }, // -- Targeting -- { name: "SENTRY_ORG", diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index e5b8711e8..579a77dc2 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -3,9 +3,10 @@ import { getConfiguredSentryUrl } from "./constants.js"; import { refreshToken } from "./db/auth.js"; import { getEnv } from "./env.js"; import { HostScopeError, ValidationError } from "./errors.js"; +import { parseSntrysClaim } from "./token-claims.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; -type McpServerConfig = { +type McpTarget = { sentryHost: string; sentryProtocol: "http" | "https"; }; @@ -59,7 +60,7 @@ export function prepareMcpServerArgs( * a long-running server picks up a fresh token without being restarted. */ export async function resolveCliMcpAccessToken( - config: McpServerConfig, + config: McpTarget, options: { force?: boolean } = {}, ): Promise { const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`; @@ -91,7 +92,7 @@ export async function resolveCliMcpAccessToken( * "run `sentry auth login`" message instead of a hung browser prompt. */ async function resolveMcpAccessTokenWithLogin( - config: McpServerConfig, + config: McpTarget, ): Promise { try { return await resolveCliMcpAccessToken(config); @@ -123,6 +124,29 @@ async function resolveMcpAccessTokenWithLogin( } } +/** + * Validate a static token from `--access-token` / `SENTRY_ACCESS_TOKEN`. + * + * The token is used as given and never refreshed. An org auth token + * (`sntrys_`) records the server it was issued for; refuse to send it to any + * other host, the same way the CLI scopes its own env tokens. + */ +export function resolveStaticMcpAccessToken( + config: McpTarget & { accessToken: string }, +): string { + const token = normalizeAuthToken(config.accessToken); + const claimedHost = parseSntrysClaim(token)?.url; + const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`; + if (claimedHost && !isHostTrusted(targetUrl, claimedHost)) { + throw new HostScopeError( + "Cannot start MCP server with the provided access token", + targetUrl, + claimedHost, + ); + } + return token; +} + /** Start the local stdio server without introducing a second auth flow. */ export async function startMcpServer(args: string[]): Promise { if (args[0] === "auth") { @@ -131,35 +155,29 @@ export async function startMcpServer(args: string[]): Promise { ); } - const { runMcpServer } = await import("@sentry/mcp-server"); - const { - SENTRY_HOST: _sentryHost, - SENTRY_URL: _sentryUrl, - ...mcpEnv - } = getEnv(); - - // Remember the resolved target so the 401 handler can refresh against the - // same host without re-parsing args. - let resolvedConfig: McpServerConfig | undefined; - - await runMcpServer(prepareMcpServerArgs(args), { - environment: mcpEnv, - packageName: "sentry mcp", - resolveAccessToken: (config) => { - resolvedConfig = config; - return resolveMcpAccessTokenWithLogin(config); - }, - onUpstreamUnauthorized: async (setAccessToken) => { - if (!resolvedConfig) { - return; - } - // Force a refresh past the usual threshold: the upstream already rejected - // the current token, so mirror `--follow`'s refresh-on-401 behavior. - const token = await resolveCliMcpAccessToken(resolvedConfig, { - force: true, - }); - setAccessToken(token); - }, - throwOnError: true, + const env = getEnv(); + const { parseMcpInvocation, serveMcpStdio } = await import("./mcp/server.js"); + const invocation = parseMcpInvocation(prepareMcpServerArgs(args), env); + if (invocation.kind === "output") { + process.stdout.write(`${invocation.text}\n`); + return; + } + + const { config } = invocation; + const { accessToken } = config; + if (accessToken) { + serveMcpStdio(config, { + accessToken: resolveStaticMcpAccessToken({ ...config, accessToken }), + env, + }); + return; + } + + serveMcpStdio(config, { + accessToken: await resolveMcpAccessTokenWithLogin(config), + // Force a refresh past the usual threshold: the upstream already rejected + // the current token, so mirror `--follow`'s refresh-on-401 behavior. + refreshAccessToken: () => resolveCliMcpAccessToken(config, { force: true }), + 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..d3dac31fb --- /dev/null +++ b/packages/cli/src/lib/mcp/args.ts @@ -0,0 +1,184 @@ +/** + * Argument and environment parsing for `sentry mcp`. + * + * Accepts the same flags and environment variables as the retired + * `@sentry/mcp-server` package, so existing MCP client configurations keep + * working when they switch to `sentry mcp`. Flags win over environment values. + */ + +import { parseArgs } from "node:util"; + +/** Values read from `sentry mcp` flags. */ +export type McpCliArgs = { + accessToken?: string; + host?: string; + insecureHttp?: boolean; + url?: string; + mcpUrl?: string; + sentryDsn?: string; + openaiBaseUrl?: string; + openaiModel?: string; + anthropicBaseUrl?: string; + anthropicModel?: string; + agentProvider?: string; + skills?: string; + allSkills?: boolean; + disableSkills?: string; + experimental?: boolean; + organizationSlug?: string; + projectSlug?: string; + help?: boolean; + version?: boolean; + unknownArgs: string[]; +}; + +/** Values read from the environment. */ +export type McpEnvArgs = { + accessToken?: string; + mcpUrl?: string; + sentryDsn?: string; + openaiModel?: string; + anthropicModel?: string; + agentProvider?: string; + skills?: string; + disableSkills?: string; +}; + +/** Flags merged over environment values, before validation. */ +export type McpMergedArgs = Omit & { + /** Whether `--agent-provider` (not the environment) chose the provider. */ + agentProviderFromFlag: boolean; +}; + +const OPTIONS = { + "access-token": { type: "string" }, + host: { type: "string" }, + "insecure-http": { type: "boolean" }, + url: { type: "string" }, + "mcp-url": { type: "string" }, + "sentry-dsn": { type: "string" }, + "openai-base-url": { type: "string" }, + "openai-model": { type: "string" }, + "anthropic-base-url": { type: "string" }, + "anthropic-model": { type: "string" }, + "agent-provider": { type: "string" }, + "organization-slug": { type: "string" }, + "project-slug": { type: "string" }, + skills: { type: "string" }, + "all-skills": { type: "boolean" }, + "disable-skills": { type: "string" }, + experimental: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, +} as const; + +/** `parseArgs` reports known short flags under their long name. */ +const KNOWN_OPTIONS = new Set(Object.keys(OPTIONS)); + +function stringValue(value: string | boolean | undefined): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** Parse `sentry mcp` flags, collecting unknown flags and positionals. */ +export function parseMcpArgv(argv: string[]): McpCliArgs { + const { values, tokens } = parseArgs({ + args: argv, + options: OPTIONS, + allowPositionals: true, + strict: false, + tokens: true, + }); + + const unknownArgs: string[] = []; + for (const token of tokens) { + if (token.kind === "option" && !KNOWN_OPTIONS.has(token.name)) { + unknownArgs.push(token.rawName); + } else if (token.kind === "positional") { + unknownArgs.push(token.value); + } + } + + return { + accessToken: stringValue(values["access-token"]), + host: stringValue(values.host), + insecureHttp: values["insecure-http"] === true, + url: stringValue(values.url), + mcpUrl: stringValue(values["mcp-url"]), + sentryDsn: stringValue(values["sentry-dsn"]), + openaiBaseUrl: stringValue(values["openai-base-url"]), + openaiModel: stringValue(values["openai-model"]), + anthropicBaseUrl: stringValue(values["anthropic-base-url"]), + anthropicModel: stringValue(values["anthropic-model"]), + agentProvider: stringValue(values["agent-provider"]), + organizationSlug: stringValue(values["organization-slug"]), + projectSlug: stringValue(values["project-slug"]), + skills: stringValue(values.skills), + allSkills: values["all-skills"] === true, + disableSkills: stringValue(values["disable-skills"]), + experimental: values.experimental === true, + help: values.help === true, + version: values.version === true, + unknownArgs, + }; +} + +/** + * Read MCP settings from the environment. + * + * `SENTRY_HOST`/`SENTRY_URL` are not read here: `sentry mcp` translates the + * CLI's configured URL into `--host`/`--url` flags before parsing. + */ +export function parseMcpEnv(env: NodeJS.ProcessEnv): McpEnvArgs { + const fromEnv: McpEnvArgs = {}; + if (env.SENTRY_ACCESS_TOKEN) { + fromEnv.accessToken = env.SENTRY_ACCESS_TOKEN; + } + if (env.MCP_URL) { + fromEnv.mcpUrl = env.MCP_URL; + } + if (env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN) { + fromEnv.sentryDsn = env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN; + } + if (env.OPENAI_MODEL) { + fromEnv.openaiModel = env.OPENAI_MODEL; + } + if (env.ANTHROPIC_MODEL) { + fromEnv.anthropicModel = env.ANTHROPIC_MODEL; + } + if (env.EMBEDDED_AGENT_PROVIDER) { + fromEnv.agentProvider = env.EMBEDDED_AGENT_PROVIDER; + } + if (env.MCP_SKILLS) { + fromEnv.skills = env.MCP_SKILLS; + } + if (env.MCP_DISABLE_SKILLS) { + fromEnv.disableSkills = env.MCP_DISABLE_SKILLS; + } + return fromEnv; +} + +/** Merge flags over environment values. */ +export function mergeMcpArgs(cli: McpCliArgs, env: McpEnvArgs): McpMergedArgs { + return { + accessToken: cli.accessToken ?? env.accessToken, + url: cli.url, + host: cli.host, + insecureHttp: cli.insecureHttp === true, + mcpUrl: cli.mcpUrl ?? env.mcpUrl, + sentryDsn: cli.sentryDsn ?? env.sentryDsn, + openaiBaseUrl: cli.openaiBaseUrl, + openaiModel: cli.openaiModel ?? env.openaiModel, + anthropicBaseUrl: cli.anthropicBaseUrl, + anthropicModel: cli.anthropicModel ?? env.anthropicModel, + agentProvider: cli.agentProvider ?? env.agentProvider, + agentProviderFromFlag: cli.agentProvider !== undefined, + // --skills and --all-skills both override MCP_SKILLS. + skills: cli.skills ?? (cli.allSkills ? undefined : env.skills), + allSkills: cli.allSkills === true, + disableSkills: cli.disableSkills ?? env.disableSkills, + experimental: cli.experimental === true, + organizationSlug: cli.organizationSlug, + projectSlug: cli.projectSlug, + unknownArgs: cli.unknownArgs, + }; +} diff --git a/packages/cli/src/lib/mcp/config.ts b/packages/cli/src/lib/mcp/config.ts new file mode 100644 index 000000000..245bd0761 --- /dev/null +++ b/packages/cli/src/lib/mcp/config.ts @@ -0,0 +1,209 @@ +/** + * Validation and defaults for `sentry mcp` settings. + * + * Turns merged flags and environment values into the server configuration: + * the Sentry target, granted skills, and embedded agent provider settings. + * The credential is resolved later, once the target host is known. + */ + +import { + ACTIVE_SKILLS, + ALL_SKILLS, + parseSkills, + type Skill, +} from "@sentry/mcp-core/skills"; +import { + validateAndParseSentryUrlThrows, + validateOpenAiBaseUrlThrows, + validateSentryHostThrows, +} from "@sentry/mcp-core/utils/url-utils"; +import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; +import { ValidationError } from "../errors.js"; +import type { McpMergedArgs } from "./args.js"; + +/** Embedded agent providers the MCP server can use. */ +export type McpAgentProvider = + | "openai" + | "azure-openai" + | "anthropic" + | "openrouter"; + +/** Validated `sentry mcp` configuration, before credential resolution. */ +export type McpServerConfig = { + /** Static token from `--access-token` or `SENTRY_ACCESS_TOKEN`. */ + accessToken?: string; + sentryHost: string; + sentryProtocol: "http" | "https"; + mcpUrl?: string; + sentryDsn?: string; + openaiBaseUrl?: string; + openaiModel?: string; + anthropicBaseUrl?: string; + anthropicModel?: string; + agentProvider?: McpAgentProvider; + agentProviderFromFlag: boolean; + experimental: boolean; + /** Skills granted for this session. */ + finalSkills: Set; + organizationSlug?: string; + projectSlug?: string; +}; + +const AGENT_PROVIDERS: ReadonlySet = new Set([ + "openai", + "azure-openai", + "anthropic", + "openrouter", +]); + +function isAgentProvider(value: string): value is McpAgentProvider { + return AGENT_PROVIDERS.has(value); +} + +/** Wrap mcp-core validation errors in the CLI's input validation error. */ +function validated(validate: () => T): T { + try { + return validate(); + } catch (error) { + throw new ValidationError( + error instanceof Error ? error.message : String(error), + ); + } +} + +export function formatInvalidSkills( + invalid: string[], + source?: string, +): string { + const prefix = source ? `${source} provided` : "Invalid skills provided"; + return `${prefix}: ${invalid.join(", ")}\nAvailable skills: ${ALL_SKILLS.join(", ")}`; +} + +/** Resolve the Sentry host from `--url` or `--host`, defaulting to sentry.io. */ +export function resolveHost(url?: string, host?: string): string { + if (url) { + return validated(() => validateAndParseSentryUrlThrows(url)); + } + if (host) { + validated(() => validateSentryHostThrows(host)); + return host; + } + return "sentry.io"; +} + +/** + * Resolve granted skills. + * + * stdio intentionally grants every active skill when `--skills` is absent, so + * a local server works without extra configuration. The hosted OAuth flow + * asks the user to choose instead. Seer is a sentry.io service: self-hosted + * autofix endpoints answer 500 without it, so it is left out of the implicit + * default there; `--skills=seer` or `--all-skills` opt back in. + */ +function resolveSkills(input: McpMergedArgs, sentryHost: string): Set { + if (input.skills && input.allSkills) { + throw new ValidationError("--all-skills cannot be combined with --skills."); + } + + let finalSkills: Set; + if (input.allSkills) { + finalSkills = new Set(ACTIVE_SKILLS); + } else if (input.skills) { + const { valid, invalid } = parseSkills(input.skills); + if (invalid.length > 0) { + throw new ValidationError(formatInvalidSkills(invalid)); + } + if (valid.size === 0) { + throw new ValidationError( + "Invalid skills provided. No valid skills found.", + ); + } + finalSkills = valid; + } else { + finalSkills = new Set(ACTIVE_SKILLS); + if (!isSentryHost(sentryHost)) { + finalSkills.delete("seer"); + } + } + + if (input.disableSkills) { + const { valid: skillsToDisable, invalid } = parseSkills( + input.disableSkills, + ); + if (invalid.length > 0) { + throw new ValidationError( + formatInvalidSkills(invalid, "--disable-skills"), + ); + } + for (const skill of skillsToDisable) { + finalSkills.delete(skill); + } + if (finalSkills.size === 0) { + throw new ValidationError( + "All skills have been disabled. At least one skill must remain enabled.", + ); + } + } + + return finalSkills; +} + +function resolveAgentProvider(value?: string): McpAgentProvider | undefined { + if (!value) { + return; + } + const provider = value.toLowerCase(); + if (!isAgentProvider(provider)) { + throw new ValidationError( + `Invalid agent provider "${value}". Must be "openai", "azure-openai", "anthropic", or "openrouter".`, + ); + } + return provider; +} + +/** Validate merged settings and apply defaults. */ +export function finalizeMcpConfig(input: McpMergedArgs): McpServerConfig { + if (input.unknownArgs.length > 0) { + throw new ValidationError( + `Invalid argument(s): ${input.unknownArgs.join(", ")}\nRun \`sentry mcp --help\` for usage.`, + ); + } + if (input.insecureHttp && input.url) { + throw new ValidationError( + "--insecure-http cannot be used with --url. Use --host for insecure self-hosted instances.", + ); + } + + const sentryHost = resolveHost(input.url, input.host); + if (input.insecureHttp && isSentryHost(sentryHost)) { + throw new ValidationError( + "--insecure-http is only supported for self-hosted Sentry hosts.", + ); + } + const finalSkills = resolveSkills(input, sentryHost); + + return { + accessToken: input.accessToken, + sentryHost, + sentryProtocol: input.insecureHttp ? "http" : "https", + mcpUrl: input.mcpUrl, + sentryDsn: input.sentryDsn, + // Anthropic base URLs follow the same rules as OpenAI ones. + openaiBaseUrl: input.openaiBaseUrl + ? validated(() => validateOpenAiBaseUrlThrows(input.openaiBaseUrl ?? "")) + : undefined, + openaiModel: input.openaiModel, + anthropicBaseUrl: input.anthropicBaseUrl + ? validated(() => + validateOpenAiBaseUrlThrows(input.anthropicBaseUrl ?? ""), + ) + : undefined, + anthropicModel: input.anthropicModel, + agentProvider: resolveAgentProvider(input.agentProvider), + agentProviderFromFlag: input.agentProviderFromFlag, + experimental: input.experimental === true, + finalSkills, + organizationSlug: input.organizationSlug, + projectSlug: input.projectSlug, + }; +} diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts new file mode 100644 index 000000000..f62b639c6 --- /dev/null +++ b/packages/cli/src/lib/mcp/server.ts @@ -0,0 +1,365 @@ +/** + * stdio MCP server host for `sentry mcp`. + * + * Parses the server flags, configures the embedded agent provider and MCP + * telemetry, then serves `@sentry/mcp-core` over stdin/stdout. Credentials are + * resolved by the caller (`lib/mcp.ts`) so this module never owns an auth flow. + * + * Loaded lazily: it pulls in mcp-core (and Zod), which normal CLI commands + * must not pay for at startup. + */ + +import { + getAgentProvider, + getResolvedProviderType, + setAgentProvider, + setProviderBaseUrls, +} from "@sentry/mcp-core/internal/agents/provider-factory"; +import { buildServer } from "@sentry/mcp-core/server"; +import { ALL_SKILLS } from "@sentry/mcp-core/skills"; +import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; +import { startStdio } from "@sentry/mcp-core/transports/stdio"; +import type { ServerContext } from "@sentry/mcp-core/types"; +import { LIB_VERSION } from "@sentry/mcp-core/version"; +import { + captureException, + consoleLoggingIntegration, + flush, + init, + vercelAIIntegration, + zodErrorsIntegration, +} from "@sentry/node"; +import { CLI_VERSION } from "../constants.js"; +import { mergeMcpArgs, parseMcpArgv, parseMcpEnv } from "./args.js"; +import { finalizeMcpConfig, type McpServerConfig } from "./config.js"; +import { buildMcpUsage } from "./usage.js"; + +/** What a `sentry mcp` invocation asks for. */ +export type McpInvocation = + | { kind: "output"; text: string } + | { kind: "serve"; config: McpServerConfig }; + +/** Credential wiring supplied by the CLI host. */ +export type McpServeOptions = { + accessToken: string; + /** + * Fetch a fresh credential after an upstream 401. Omitted for static + * tokens, which are never refreshed. + */ + refreshAccessToken?: () => Promise; + env: NodeJS.ProcessEnv; +}; + +const SENTRY_FLUSH_TIMEOUT_MS = 5000; + +/** Parse `sentry mcp` arguments into help/version output or a server config. */ +export function parseMcpInvocation( + args: string[], + env: NodeJS.ProcessEnv, +): McpInvocation { + const cli = parseMcpArgv(args); + if (cli.help) { + return { kind: "output", text: buildMcpUsage(ALL_SKILLS) }; + } + if (cli.version) { + return { kind: "output", text: `sentry mcp ${CLI_VERSION}` }; + } + return { + kind: "serve", + config: finalizeMcpConfig(mergeMcpArgs(cli, parseMcpEnv(env))), + }; +} + +type ProviderMismatch = { configured: string; availableKey: string }; + +/** The configured provider's key is missing while another provider's is set. */ +function findProviderMismatch( + configured: string | undefined, + env: NodeJS.ProcessEnv, +): ProviderMismatch | undefined { + const keys = { + openai: Boolean(env.OPENAI_API_KEY), + anthropic: Boolean(env.ANTHROPIC_API_KEY), + openrouter: Boolean(env.OPENROUTER_API_KEY), + }; + if (configured === "openai" || configured === "azure-openai") { + if (!keys.openai && (keys.anthropic || keys.openrouter)) { + return { + configured, + availableKey: keys.openrouter + ? "OPENROUTER_API_KEY" + : "ANTHROPIC_API_KEY", + }; + } + } else if (configured === "anthropic") { + if (!keys.anthropic && (keys.openai || keys.openrouter)) { + return { + configured, + availableKey: keys.openrouter ? "OPENROUTER_API_KEY" : "OPENAI_API_KEY", + }; + } + } else if ( + configured === "openrouter" && + !keys.openrouter && + (keys.openai || keys.anthropic) + ) { + return { + configured, + availableKey: keys.openai ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY", + }; + } + return; +} + +function expectedProviderKey(provider: string): string { + if (provider === "openai" || provider === "azure-openai") { + return "OPENAI_API_KEY"; + } + return provider === "openrouter" ? "OPENROUTER_API_KEY" : "ANTHROPIC_API_KEY"; +} + +/** Explain why AI-powered search tools are unavailable. */ +function warnNoProvider(config: McpServerConfig, env: NodeJS.ProcessEnv): void { + const configured = + config.agentProvider ?? env.EMBEDDED_AGENT_PROVIDER?.toLowerCase(); + const providerKeyCount = [ + env.ANTHROPIC_API_KEY, + env.OPENAI_API_KEY, + env.OPENROUTER_API_KEY, + ].filter(Boolean).length; + const mismatch = findProviderMismatch(configured, env); + + let providerConfigError: string | undefined; + try { + getAgentProvider(); + } catch (error) { + providerConfigError = + error instanceof Error ? error.message : String(error); + } + + if (providerKeyCount > 1 && !configured) { + console.warn( + "Warning: Multiple LLM API keys are set, but no provider is explicitly configured.", + ); + console.warn( + "Please set EMBEDDED_AGENT_PROVIDER='openai', 'azure-openai', 'anthropic', or 'openrouter' to specify which provider to use.", + ); + console.warn( + "AI-powered search tools will be unavailable until a provider is selected.", + ); + } else if (mismatch) { + const expectedKey = expectedProviderKey(mismatch.configured); + const providerSetting = config.agentProviderFromFlag + ? `--agent-provider=${mismatch.configured}` + : `EMBEDDED_AGENT_PROVIDER='${mismatch.configured}'`; + const changeProviderHint = config.agentProviderFromFlag + ? "Change --agent-provider to match your available API key" + : "Change EMBEDDED_AGENT_PROVIDER to match your available API key"; + console.warn(`Warning: ${providerSetting} but ${expectedKey} is not set.`); + console.warn(`Found ${mismatch.availableKey} instead. Either:`); + console.warn( + ` - Set ${expectedKey} to use the ${mismatch.configured} provider, or`, + ); + console.warn(` - ${changeProviderHint}`); + console.warn( + "AI-powered search tools will be unavailable until this is resolved.", + ); + } else if ( + providerConfigError && + !providerConfigError.startsWith("No embedded agent provider configured") + ) { + console.warn(`Warning: ${providerConfigError}`); + console.warn( + "AI-powered search tools will be unavailable until this is resolved.", + ); + } else { + console.warn( + "Warning: No LLM API key found (OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY).", + ); + console.warn("AI-powered search tools will be unavailable."); + console.warn( + "Search tools still work with direct Sentry query syntax via the 'query' parameter.", + ); + } + console.warn(""); +} + +/** Configure the embedded agent provider and report which one is active. */ +function configureAgentProvider( + config: McpServerConfig, + env: NodeJS.ProcessEnv, +): void { + if (config.agentProvider) { + setAgentProvider(config.agentProvider); + } + setProviderBaseUrls({ + openaiBaseUrl: config.openaiBaseUrl, + anthropicBaseUrl: config.anthropicBaseUrl, + }); + // The provider factory reads model overrides from the environment. + if (config.openaiModel) { + env.OPENAI_MODEL = config.openaiModel; + } + if (config.anthropicModel) { + env.ANTHROPIC_MODEL = config.anthropicModel; + } + + const resolvedProvider = getResolvedProviderType(); + if (!resolvedProvider) { + warnNoProvider(config, env); + return; + } + + let providerSource = "auto-detected"; + if (config.agentProviderFromFlag) { + providerSource = "explicitly configured"; + } else if (env.EMBEDDED_AGENT_PROVIDER) { + providerSource = "from EMBEDDED_AGENT_PROVIDER"; + } + console.warn( + `Using ${getAgentProvider().label} for AI-powered search tools (${providerSource}).`, + ); + if (providerSource === "auto-detected") { + console.warn( + "Deprecation warning: Auto-detection of LLM provider is deprecated.", + ); + console.warn( + `Please set EMBEDDED_AGENT_PROVIDER='${resolvedProvider}' explicitly.`, + ); + console.warn("Auto-detection will be removed in a future release."); + } + console.warn(""); +} + +function initMcpTelemetry( + config: McpServerConfig, + env: NodeJS.ProcessEnv, +): void { + const scopeContext = { + "app.server.version": LIB_VERSION, + "app.transport": "stdio", + "app.upstream.host": config.sentryHost, + "app.url.full": config.mcpUrl, + }; + + init({ + dsn: config.sentryDsn, + tracesSampleRate: 1, + beforeSend: sentryBeforeSend, + initialScope: { + tags: { + ...scopeContext, + "app.server.mode.experimental": config.experimental ? "true" : "false", + }, + // SDK v11 does not copy scope tags onto streamed spans. + attributes: { + ...scopeContext, + "app.server.mode.experimental": config.experimental, + }, + }, + release: env.SENTRY_RELEASE, + integrations: [ + consoleLoggingIntegration(), + zodErrorsIntegration(), + vercelAIIntegration({ recordInputs: true, recordOutputs: true }), + ], + environment: + env.SENTRY_ENVIRONMENT ?? + (env.NODE_ENV !== "production" ? "development" : "production"), + }); +} + +/** Exit on fatal errors without recursing when stderr is already closed. */ +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 { + console.error(`${signal} received, shutting down...`); + await flush(SENTRY_FLUSH_TIMEOUT_MS); + process.exit(0); + } + + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("uncaughtException", (error) => { + exitAfterError(error, "Uncaught exception:"); + }); + process.on("unhandledRejection", (reason) => { + exitAfterError(reason, "Unhandled rejection:"); + }); + return exitAfterError; +} + +/** Serve the MCP tools over stdio until the client disconnects. */ +export function serveMcpStdio( + config: McpServerConfig, + options: McpServeOptions, +): void { + configureAgentProvider(config, options.env); + initMcpTelemetry(config, options.env); + + if (config.experimental) { + console.warn( + "Experimental mode enabled: Forward-looking tool variants and experimental features are available.", + ); + console.warn(""); + } + + const exitAfterError = installProcessHandlers(); + const { refreshAccessToken } = options; + 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", + // Tool handlers read `context.accessToken` per call, so swapping it here + // lets a long-running server recover from an expired CLI session. + onUpstreamUnauthorized: refreshAccessToken + ? async () => { + context.accessToken = await refreshAccessToken(); + } + : undefined, + }; + + const server = buildServer({ + context, + experimentalMode: config.experimental, + }); + startStdio(server, context).catch((error: unknown) => { + exitAfterError(error, "Server error:"); + }); +} diff --git a/packages/cli/src/lib/mcp/usage.ts b/packages/cli/src/lib/mcp/usage.ts new file mode 100644 index 000000000..7e927453b --- /dev/null +++ b/packages/cli/src/lib/mcp/usage.ts @@ -0,0 +1,70 @@ +/** Help text for `sentry mcp --help`. */ + +import type { Skill } from "@sentry/mcp-core/skills"; + +const COMMAND = "sentry mcp"; + +/** Build the `sentry mcp --help` text. */ +export function buildMcpUsage(allSkills: ReadonlyArray): string { + return `Usage: ${COMMAND} [--access-token=] [--host=] [--insecure-http] + +Authentication: + Uses the active Sentry CLI session by default. Authenticate with \`sentry auth login\`. + --access-token Use this Sentry auth token instead of the CLI session + (never refreshed; org auth tokens must match the selected host) + +Common optional flags: + --host Change Sentry host (self-hosted) + --url Change Sentry URL (https only; use --host for http) + --insecure-http Use http:// for self-hosted --host values + --mcp-url MCP server URL reported in telemetry + --sentry-dsn Override DSN used for telemetry reporting + --experimental Enable forward-looking tool variants and experimental features + -h, --help Show this help + -v, --version Show the version + +Embedded agent configuration: + --agent-provider LLM provider: openai, azure-openai, anthropic, or openrouter (auto-detects from API keys) + --openai-base-url Override OpenAI API base URL + --openai-model Override OpenAI model (default: gpt-5) + --anthropic-base-url Override Anthropic API base URL + --anthropic-model Override Anthropic model (default: claude-sonnet-4-5) + +Session constraints: + --organization-slug Force all calls to an organization + --project-slug Optional project constraint + +Skill controls: + --skills Specify which skills to grant (default: active, non-deprecated skills; + seer is excluded by default on self-hosted hosts) + --all-skills Grant all active, non-deprecated skills (including seer on self-hosted) + --disable-skills Remove specific skills (e.g. --disable-skills=seer) + +Available skills: ${allSkills.join(", ")} + +Environment variables: + SENTRY_ACCESS_TOKEN Static Sentry token (alternative to --access-token) + SENTRY_HOST, SENTRY_URL Sentry instance (same as for other sentry commands) + MCP_URL MCP server URL reported in telemetry + SENTRY_DSN DSN used for telemetry reporting + OPENAI_API_KEY OpenAI API key for AI-powered search tools + OPENAI_MODEL OpenAI model override + ANTHROPIC_API_KEY Anthropic API key for AI-powered search tools + ANTHROPIC_MODEL Anthropic model override + OPENROUTER_API_KEY OpenRouter API key for AI-powered search tools + OPENROUTER_MODEL OpenRouter model (default: openai/gpt-5.6-luna) + OPENROUTER_REASONING_EFFORT OpenRouter reasoning effort: none|minimal|low|medium|high|xhigh (max→xhigh), or "" to omit (default: high) + EMBEDDED_AGENT_PROVIDER Provider override: openai, azure-openai, anthropic, or openrouter + MCP_SKILLS Specify which skills to grant (comma-separated) + MCP_DISABLE_SKILLS Disable specific skills (comma-separated) + +Examples: + ${COMMAND} + ${COMMAND} --all-skills + ${COMMAND} --skills=inspect,triage + ${COMMAND} --host=sentry.example.com + ${COMMAND} --host=sentry.internal:9000 --insecure-http + ${COMMAND} --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer + ${COMMAND} --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ + ${COMMAND} --agent-provider=anthropic`; +} diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 79af97885..95607f6ba 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -1,28 +1,32 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { setAuthToken } from "../../src/lib/db/auth.js"; +import { clearAuth, setAuthToken } from "../../src/lib/db/auth.js"; import { + HostScopeError, + MalformedAuthTokenError, +} from "../../src/lib/errors.js"; +import type { McpServerConfig } from "../../src/lib/mcp/config.js"; +import type { McpServeOptions } from "../../src/lib/mcp/server.js"; +import { + mintSntrysToken, resetHostScopingState, useEnvSandbox, useTestConfigDir, } from "../helpers.js"; -type CapturedOptions = { - resolveAccessToken?: (config: unknown) => Promise; - onUpstreamUnauthorized?: ( - setAccessToken: (token: string) => void, - ) => void | Promise; -}; +type Served = { config: McpServerConfig; options: McpServeOptions }; -const captured: { options?: CapturedOptions } = {}; +const served: { calls: Served[] } = { calls: [] }; -vi.mock("@sentry/mcp-server", () => ({ - runMcpServer: vi.fn(async (_args: string[], options: CapturedOptions) => { - captured.options = options; +vi.mock("../../src/lib/mcp/server.js", async (importOriginal) => ({ + ...(await importOriginal()), + serveMcpStdio: vi.fn((config: McpServerConfig, options: McpServeOptions) => { + served.calls.push({ config, options }); }), })); useTestConfigDir("mcp-start-"); useEnvSandbox([ + "SENTRY_ACCESS_TOKEN", "SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_FORCE_ENV_TOKEN", @@ -31,70 +35,162 @@ useEnvSandbox([ ]); beforeEach(async () => { - captured.options = undefined; + served.calls = []; await resetHostScopingState(); }); afterEach(async () => { + vi.restoreAllMocks(); await resetHostScopingState(); }); +async function start(args: string[]): Promise { + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await startMcpServer(args); + expect(served.calls).toHaveLength(1); + const [call] = served.calls; + if (!call) { + throw new Error("serveMcpStdio was not called"); + } + return call; +} + describe("startMcpServer credential wiring", () => { - test("resolves the CLI session token for the MCP server", async () => { + test("serves with the CLI session token", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", }); - const { startMcpServer } = await import("../../src/lib/mcp.js"); - await startMcpServer([]); - - const resolve = captured.options?.resolveAccessToken; - expect(resolve).toBeDefined(); - await expect( - resolve?.({ sentryHost: "sentry.io", sentryProtocol: "https" }), - ).resolves.toBe("cli-session-token"); + const { options } = await start([]); + expect(options.accessToken).toBe("cli-session-token"); + expect(options.refreshAccessToken).toBeDefined(); }); - test("refreshes and writes back a new token on upstream 401", async () => { + test("refreshes the CLI session token after an upstream 401", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", }); - - const { startMcpServer } = await import("../../src/lib/mcp.js"); - await startMcpServer([]); - - // The 401 handler needs the resolved target; prime it via resolveAccessToken. - await captured.options?.resolveAccessToken?.({ - sentryHost: "sentry.io", - sentryProtocol: "https", - }); + const { options } = await start([]); // Simulate a refreshed credential landing in the store before the handler runs. setAuthToken("rotated-token", undefined, undefined, { host: "https://sentry.io", }); + await expect(options.refreshAccessToken?.()).resolves.toBe("rotated-token"); + }); +}); - let written: string | undefined; - await captured.options?.onUpstreamUnauthorized?.((token) => { - written = token; - }); +describe("startMcpServer static tokens", () => { + test("uses --access-token without a CLI session and never refreshes it", async () => { + await clearAuth(); + + const { config, options } = await start(["--access-token=static-token"]); + expect(options.accessToken).toBe("static-token"); + expect(options.refreshAccessToken).toBeUndefined(); + expect(config.sentryHost).toBe("sentry.io"); + }); + + test("uses SENTRY_ACCESS_TOKEN without a CLI session", async () => { + await clearAuth(); + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; - expect(written).toBe("rotated-token"); + const { options } = await start([]); + expect(options.accessToken).toBe("env-static-token"); + expect(options.refreshAccessToken).toBeUndefined(); }); - test("ignores an upstream 401 before any token has been resolved", async () => { + test("prefers --access-token over SENTRY_ACCESS_TOKEN", async () => { + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + + const { options } = await start(["--access-token", "flag-token"]); + expect(options.accessToken).toBe("flag-token"); + }); + + test("prefers a static token over the CLI session", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", }); + const { options } = await start(["--access-token=static-token"]); + expect(options.accessToken).toBe("static-token"); + }); + + test("normalizes a padded static token", async () => { + const { options } = await start(["--access-token=static-token\n"]); + expect(options.accessToken).toBe("static-token"); + }); + + test("rejects a malformed static token", async () => { const { startMcpServer } = await import("../../src/lib/mcp.js"); - await startMcpServer([]); + await expect( + startMcpServer(["--access-token=has a space"]), + ).rejects.toBeInstanceOf(MalformedAuthTokenError); + expect(served.calls).toHaveLength(0); + }); + + test("serves a static token on the self-hosted host it is configured for", async () => { + const { config, options } = await start([ + "--access-token=static-token", + "--host=sentry.internal:9000", + "--insecure-http", + ]); + expect(options.accessToken).toBe("static-token"); + expect(config.sentryHost).toBe("sentry.internal:9000"); + expect(config.sentryProtocol).toBe("http"); + }); + + test("routes SENTRY_HOST to the static token target", async () => { + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + + const { config } = await start([]); + expect(config.sentryHost).toBe("sentry.example.com"); + }); - let written: string | undefined; - await captured.options?.onUpstreamUnauthorized?.((token) => { - written = token; + 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", }); - expect(written).toBeUndefined(); + const { options } = await start([ + `--access-token=${token}`, + "--host=sentry.example.com", + ]); + expect(options.accessToken).toBe(token); }); + + test("refuses to send an org auth token to another host", async () => { + const token = mintSntrysToken({ + iat: 1, + url: "https://sentry.example.com", + org: "acme", + }); + + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await expect( + startMcpServer([`--access-token=${token}`, "--host=evil.example.com"]), + ).rejects.toBeInstanceOf(HostScopeError); + expect(served.calls).toHaveLength(0); + }); +}); + +describe("startMcpServer output flags", () => { + test.each([["--help"], ["--version"]])( + "prints %s to stdout without serving", + async (flag) => { + const write = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const { startMcpServer } = await import("../../src/lib/mcp.js"); + + await startMcpServer([flag]); + + expect(served.calls).toHaveLength(0); + expect(write).toHaveBeenCalledWith( + expect.stringMatching(/^(Usage: sentry mcp|sentry mcp \S+)/), + ); + }, + ); }); diff --git a/packages/cli/test/lib/mcp/args.test.ts b/packages/cli/test/lib/mcp/args.test.ts new file mode 100644 index 000000000..a6a9823b6 --- /dev/null +++ b/packages/cli/test/lib/mcp/args.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; +import { + mergeMcpArgs, + parseMcpArgv, + parseMcpEnv, +} from "../../../src/lib/mcp/args.js"; + +describe("parseMcpArgv", () => { + it("parses known flags and short aliases", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--host=sentry.io", + "--insecure-http", + "--url=https://example.com", + "--mcp-url=https://mcp.example.com", + "--sentry-dsn=dsn", + "--openai-base-url=https://api.example.com/v1", + "--skills=inspect,triage", + "--all-skills", + "-h", + "-v", + ]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.host).toBe("sentry.io"); + expect(parsed.insecureHttp).toBe(true); + expect(parsed.url).toBe("https://example.com"); + expect(parsed.mcpUrl).toBe("https://mcp.example.com"); + expect(parsed.sentryDsn).toBe("dsn"); + expect(parsed.openaiBaseUrl).toBe("https://api.example.com/v1"); + expect(parsed.skills).toBe("inspect,triage"); + expect(parsed.allSkills).toBe(true); + expect(parsed.help).toBe(true); + expect(parsed.version).toBe(true); + expect(parsed.unknownArgs).toEqual([]); + }); + + it("parses skills flags", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--skills=inspect,triage", + ]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.skills).toBe("inspect,triage"); + }); + + it("parses --disable-skills", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--disable-skills=seer", + ]); + expect(parsed.disableSkills).toBe("seer"); + }); + + it("parses --all-skills", () => { + const parsed = parseMcpArgv(["--access-token=tok", "--all-skills"]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.allSkills).toBe(true); + }); + + it("parses --insecure-http", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--host=sentry.internal:9000", + "--insecure-http", + ]); + expect(parsed.host).toBe("sentry.internal:9000"); + expect(parsed.insecureHttp).toBe(true); + }); + + it("parses --agent-provider=azure-openai", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--agent-provider=azure-openai", + ]); + expect(parsed.agentProvider).toBe("azure-openai"); + }); + + it("parses --agent-provider=openrouter", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--agent-provider=openrouter", + ]); + expect(parsed.agentProvider).toBe("openrouter"); + }); + + it("collects unknown args", () => { + const parsed = parseMcpArgv(["--unknown", "--another=1"]); + expect(parsed.unknownArgs.length).toBeGreaterThan(0); + }); +}); + +describe("parseMcpEnv", () => { + it("parses environment variables including skills", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_URL: "envmcp", + SENTRY_DSN: "envdsn", + MCP_SKILLS: "inspect,triage", + }); + expect(env.accessToken).toBe("envtok"); + expect(env.mcpUrl).toBe("envmcp"); + expect(env.sentryDsn).toBe("envdsn"); + expect(env.skills).toBe("inspect,triage"); + }); + + it("reads MCP_DISABLE_SKILLS", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_DISABLE_SKILLS: "seer", + }); + expect(env.disableSkills).toBe("seer"); + }); +}); + +describe("mergeMcpArgs", () => { + it("applies precedence: CLI over env", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_URL: "envmcp", + SENTRY_DSN: "envdsn", + }); + const cli = parseMcpArgv([ + "--access-token=clitok", + "--host=clihost", + "--insecure-http", + "--mcp-url=climcp", + "--sentry-dsn=clidsn", + "--openai-base-url=https://api.cli/v1", + ]); + const merged = mergeMcpArgs(cli, env); + expect(merged.accessToken).toBe("clitok"); + expect(merged.host).toBe("clihost"); + expect(merged.insecureHttp).toBe(true); + expect(merged.mcpUrl).toBe("climcp"); + expect(merged.sentryDsn).toBe("clidsn"); + expect(merged.openaiBaseUrl).toBe("https://api.cli/v1"); + }); + + it("applies precedence for skills: CLI over env", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect", + }); + const cli = parseMcpArgv([ + "--access-token=clitok", + "--skills=inspect,triage", + ]); + const merged = mergeMcpArgs(cli, env); + expect(merged.skills).toBe("inspect,triage"); + }); + + it("falls back to env when CLI skills not provided", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect,triage", + }); + const cli = parseMcpArgv(["--access-token=clitok"]); + const merged = mergeMcpArgs(cli, env); + expect(merged.skills).toBe("inspect,triage"); + }); + + it("lets CLI allSkills override env skills", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect", + }); + const cli = parseMcpArgv(["--access-token=clitok", "--all-skills"]); + const merged = mergeMcpArgs(cli, env); + expect(merged.allSkills).toBe(true); + expect(merged.skills).toBeUndefined(); + }); + + it("applies precedence for disableSkills: CLI over env", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_DISABLE_SKILLS: "docs", + }); + const cli = parseMcpArgv([ + "--access-token=clitok", + "--disable-skills=seer", + ]); + const merged = mergeMcpArgs(cli, env); + expect(merged.disableSkills).toBe("seer"); + }); + + it("falls back to env when CLI disableSkills not provided", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_DISABLE_SKILLS: "seer", + }); + const cli = parseMcpArgv(["--access-token=clitok"]); + const merged = mergeMcpArgs(cli, env); + expect(merged.disableSkills).toBe("seer"); + }); + + it("applies precedence for agentProvider: CLI over env", () => { + const env = parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + EMBEDDED_AGENT_PROVIDER: "openai", + }); + const cli = parseMcpArgv([ + "--access-token=clitok", + "--agent-provider=azure-openai", + ]); + const merged = mergeMcpArgs(cli, env); + expect(merged.agentProvider).toBe("azure-openai"); + }); +}); + +describe("@sentry/mcp-server flag parity", () => { + it("accepts every standalone server flag", () => { + const parsed = parseMcpArgv([ + "--access-token=tok", + "--host=sentry.example.com", + "--insecure-http", + "--url=https://sentry.example.com", + "--mcp-url=https://mcp.example.com", + "--sentry-dsn=dsn", + "--openai-base-url=https://openai.example.com/v1", + "--openai-model=gpt-test", + "--anthropic-base-url=https://anthropic.example.com", + "--anthropic-model=claude-test", + "--agent-provider=anthropic", + "--organization-slug=acme", + "--project-slug=web", + "--skills=inspect", + "--all-skills", + "--disable-skills=seer", + "--experimental", + "--help", + "--version", + ]); + expect(parsed).toEqual({ + accessToken: "tok", + host: "sentry.example.com", + insecureHttp: true, + url: "https://sentry.example.com", + mcpUrl: "https://mcp.example.com", + sentryDsn: "dsn", + openaiBaseUrl: "https://openai.example.com/v1", + openaiModel: "gpt-test", + anthropicBaseUrl: "https://anthropic.example.com", + anthropicModel: "claude-test", + agentProvider: "anthropic", + organizationSlug: "acme", + projectSlug: "web", + skills: "inspect", + allSkills: true, + disableSkills: "seer", + experimental: true, + help: true, + version: true, + unknownArgs: [], + }); + }); + + it("accepts space-separated flag values", () => { + const parsed = parseMcpArgv(["--access-token", "tok", "--host", "h.test"]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.host).toBe("h.test"); + }); + + it("reports unknown flags and positionals by their raw spelling", () => { + expect(parseMcpArgv(["--bogus=1", "-x", "extra"]).unknownArgs).toEqual([ + "--bogus", + "-x", + "extra", + ]); + }); + + it("reads every standalone server environment variable", () => { + expect( + parseMcpEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_URL: "https://mcp.example.com", + SENTRY_DSN: "dsn", + OPENAI_MODEL: "gpt-test", + ANTHROPIC_MODEL: "claude-test", + EMBEDDED_AGENT_PROVIDER: "openrouter", + MCP_SKILLS: "inspect", + MCP_DISABLE_SKILLS: "seer", + }), + ).toEqual({ + accessToken: "envtok", + mcpUrl: "https://mcp.example.com", + sentryDsn: "dsn", + openaiModel: "gpt-test", + anthropicModel: "claude-test", + agentProvider: "openrouter", + skills: "inspect", + disableSkills: "seer", + }); + }); + + it("falls back to DEFAULT_SENTRY_DSN for telemetry", () => { + expect(parseMcpEnv({ DEFAULT_SENTRY_DSN: "default" }).sentryDsn).toBe( + "default", + ); + }); + + it("uses SENTRY_ACCESS_TOKEN when --access-token is absent", () => { + const merged = mergeMcpArgs( + parseMcpArgv([]), + parseMcpEnv({ SENTRY_ACCESS_TOKEN: "envtok" }), + ); + expect(merged.accessToken).toBe("envtok"); + }); + + it("records whether the agent provider came from the flag", () => { + const env = parseMcpEnv({ EMBEDDED_AGENT_PROVIDER: "openai" }); + expect(mergeMcpArgs(parseMcpArgv([]), env).agentProviderFromFlag).toBe( + false, + ); + expect( + mergeMcpArgs(parseMcpArgv(["--agent-provider=openai"]), env) + .agentProviderFromFlag, + ).toBe(true); + }); +}); diff --git a/packages/mcp-server/src/cli/resolve.test.ts b/packages/cli/test/lib/mcp/config.test.ts similarity index 85% rename from packages/mcp-server/src/cli/resolve.test.ts rename to packages/cli/test/lib/mcp/config.test.ts index d3fe56251..c23d17ad7 100644 --- a/packages/mcp-server/src/cli/resolve.test.ts +++ b/packages/cli/test/lib/mcp/config.test.ts @@ -1,25 +1,20 @@ -import { describe, it, expect } from "vitest"; -import { finalize } from "./resolve"; +import { describe, expect, it } from "vitest"; +import type { McpMergedArgs } from "../../../src/lib/mcp/args.js"; +import { finalizeMcpConfig } from "../../../src/lib/mcp/config.js"; +import { ValidationError } from "../../../src/lib/errors.js"; -describe("cli/finalize", () => { - it("returns undefined accessToken when none provided", () => { - const cfg = finalize({ unknownArgs: [] } as any); - expect(cfg.accessToken).toBeUndefined(); - }); - - it("uses DEFAULT_SENTRY_CLIENT_ID when no clientId provided", () => { - const cfg = finalize({ accessToken: "tok", unknownArgs: [] }); - expect(cfg.clientId).toBeDefined(); - expect(typeof cfg.clientId).toBe("string"); +function finalize(input: Partial) { + return finalizeMcpConfig({ + agentProviderFromFlag: false, + unknownArgs: [], + ...input, }); +} - it("uses provided clientId over default", () => { - const cfg = finalize({ - accessToken: "tok", - clientId: "custom-client-id", - unknownArgs: [], - }); - expect(cfg.clientId).toBe("custom-client-id"); +describe("finalizeMcpConfig", () => { + it("returns undefined accessToken when none provided", () => { + const cfg = finalize({}); + expect(cfg.accessToken).toBeUndefined(); }); it("normalizes host from URL", () => { @@ -106,7 +101,7 @@ describe("cli/finalize", () => { insecureHttp: true, unknownArgs: [], }), - ).toThrow(/cannot be used with --url or SENTRY_URL/); + ).toThrow(/cannot be used with --url/); }); it("surfaces the --insecure-http + --url conflict before URL validation", () => { @@ -120,7 +115,7 @@ describe("cli/finalize", () => { insecureHttp: true, unknownArgs: [], }), - ).toThrow(/cannot be used with --url or SENTRY_URL/); + ).toThrow(/cannot be used with --url/); }); it.each(["sentry.io", "example.my.sentry.io"])( @@ -377,4 +372,44 @@ describe("cli/finalize", () => { expect(cfg.finalSkills.has("inspect")).toBe(true); expect(cfg.finalSkills.has("triage")).toBe(true); }); + + it("rejects unknown arguments with a pointer to --help", () => { + expect(() => finalize({ unknownArgs: ["--bogus"] })).toThrow( + /Invalid argument\(s\): --bogus\nRun `sentry mcp --help`/, + ); + }); + + it("reports invalid input as a CLI validation error", () => { + expect(() => finalize({ url: "http://bad" })).toThrow(ValidationError); + expect(() => finalize({ skills: "nope" })).toThrow(ValidationError); + }); + + it("keeps the static token, constraints, and embedded agent settings", () => { + const cfg = finalize({ + accessToken: "tok", + mcpUrl: "https://mcp.example.com", + sentryDsn: "dsn", + openaiModel: "gpt-test", + anthropicBaseUrl: "https://anthropic.example.com", + anthropicModel: "claude-test", + agentProvider: "Anthropic", + agentProviderFromFlag: true, + experimental: true, + organizationSlug: "acme", + projectSlug: "web", + }); + expect(cfg).toMatchObject({ + accessToken: "tok", + mcpUrl: "https://mcp.example.com", + sentryDsn: "dsn", + openaiModel: "gpt-test", + anthropicBaseUrl: "https://anthropic.example.com/", + anthropicModel: "claude-test", + agentProvider: "anthropic", + agentProviderFromFlag: true, + experimental: true, + organizationSlug: "acme", + projectSlug: "web", + }); + }); }); diff --git a/packages/cli/test/lib/mcp/usage.test.ts b/packages/cli/test/lib/mcp/usage.test.ts new file mode 100644 index 000000000..f4ab04146 --- /dev/null +++ b/packages/cli/test/lib/mcp/usage.test.ts @@ -0,0 +1,88 @@ +import { ALL_SKILLS } from "@sentry/mcp-core/skills"; +import { describe, expect, it } from "vitest"; +import { CLI_VERSION } from "../../../src/lib/constants.js"; +import { parseMcpInvocation } from "../../../src/lib/mcp/server.js"; +import { buildMcpUsage } from "../../../src/lib/mcp/usage.js"; + +describe("buildMcpUsage", () => { + it("documents CLI session auth and the static token override", () => { + const usage = buildMcpUsage(["inspect"]); + + expect(usage).toContain("Uses the active Sentry CLI session"); + expect(usage).toContain("sentry auth login"); + expect(usage).toContain("--access-token "); + expect(usage).toContain("SENTRY_ACCESS_TOKEN"); + expect(usage).toContain("Available skills: inspect"); + expect(usage).not.toContain("device code"); + expect(usage).not.toContain("auth [login|logout|status]"); + }); + + it("lists every server flag", () => { + const usage = buildMcpUsage(ALL_SKILLS); + for (const flag of [ + "--access-token", + "--host", + "--url", + "--insecure-http", + "--mcp-url", + "--sentry-dsn", + "--experimental", + "--help", + "--version", + "--agent-provider", + "--openai-base-url", + "--openai-model", + "--anthropic-base-url", + "--anthropic-model", + "--organization-slug", + "--project-slug", + "--skills", + "--all-skills", + "--disable-skills", + ]) { + expect(usage).toContain(flag); + } + }); +}); + +describe("parseMcpInvocation", () => { + it.each([["--help"], ["-h"]])("prints usage for %s", (flag) => { + expect(parseMcpInvocation([flag], {})).toEqual({ + kind: "output", + text: buildMcpUsage(ALL_SKILLS), + }); + }); + + it.each([["--version"], ["-v"]])("prints the CLI version for %s", (flag) => { + expect(parseMcpInvocation([flag], {})).toEqual({ + kind: "output", + text: `sentry mcp ${CLI_VERSION}`, + }); + }); + + it("prints help even when other arguments are invalid", () => { + expect(parseMcpInvocation(["--bogus", "--help"], {}).kind).toBe("output"); + }); + + it("builds a server config from flags and the environment", () => { + const invocation = parseMcpInvocation( + ["--host=sentry.example.com", "--skills=inspect"], + { SENTRY_ACCESS_TOKEN: "envtok" }, + ); + expect(invocation).toMatchObject({ + kind: "serve", + config: { + accessToken: "envtok", + sentryHost: "sentry.example.com", + sentryProtocol: "https", + finalSkills: new Set(["inspect"]), + }, + }); + }); + + it("rejects unknown arguments", () => { + expect(() => parseMcpInvocation(["--bogus"], {})).toThrow( + "Invalid argument(s): --bogus", + ); + }); +}); diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/amp.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/amp.tsx index a18e116b6..5768ca44e 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/amp.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/amp.tsx @@ -47,7 +47,7 @@ export function AmpInstructions({ transport }: AmpInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["@sentry/mcp-server@latest"], + args: ["-y", "sentry@latest", "mcp"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/claude-code.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/claude-code.tsx index 1308d1880..aaf09157f 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/claude-code.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/claude-code.tsx @@ -44,7 +44,7 @@ export function ClaudeCodeInstructions({ } // Stdio transport - const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`; + const mcpStdioSnippet = `npx -y ${NPM_PACKAGE_NAME}@latest mcp`; return ( <>
    diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/codex-cli.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/codex-cli.tsx index 7dd8fe41c..d7e8a0a30 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/codex-cli.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/codex-cli.tsx @@ -52,7 +52,7 @@ export function CodexCLIInstructions({ transport }: CodexCLIInstructionsProps) { const codexConfigToml = [ "[mcp_servers.sentry]", 'command = "npx"', - 'args = ["@sentry/mcp-server@latest"]', + 'args = ["-y", "sentry@latest", "mcp"]', 'env = { SENTRY_ACCESS_TOKEN = "sentry-user-token", OPENAI_API_KEY = "your-openai-key" }', ].join("\n"); const selfHostedEnvLine = diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/cursor.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/cursor.tsx index fa6b888c0..ca482f60d 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/cursor.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/cursor.tsx @@ -71,7 +71,7 @@ export function CursorInstructions({ transport }: CursorInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["@sentry/mcp-server@latest"], + args: ["-y", "sentry@latest", "mcp"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx index b6b746eb6..8853f3740 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx @@ -42,7 +42,7 @@ export function FxInstructions({ transport }: FxInstructionsProps) { } // Stdio transport - const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`; + const mcpStdioSnippet = `npx -y ${NPM_PACKAGE_NAME}@latest mcp`; return ( <>
      diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/gemini.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/gemini.tsx index 778e23ec1..a4816b8e3 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/gemini.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/gemini.tsx @@ -64,7 +64,7 @@ export function GeminiInstructions({ transport }: GeminiInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["@sentry/mcp-server@latest"], + args: ["-y", "sentry@latest", "mcp"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/opencode.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/opencode.tsx index c77e118ae..c7e44dc00 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/opencode.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/opencode.tsx @@ -65,7 +65,7 @@ export function OpenCodeInstructions({ transport }: OpenCodeInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["@sentry/mcp-server@latest"], + args: ["-y", "sentry@latest", "mcp"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/pi.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/pi.tsx index f3e45ca4f..edce12447 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/pi.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/pi.tsx @@ -54,7 +54,7 @@ export function PiInstructions({ transport }: PiInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["@sentry/mcp-server@latest"], + args: ["-y", "sentry@latest", "mcp"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/vscode.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/vscode.tsx index 84edfea31..ced71c9b7 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/vscode.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/vscode.tsx @@ -66,14 +66,14 @@ export function VSCodeInstructions({ transport }: VSCodeInstructionsProps) { } // Stdio transport - const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`; + const mcpStdioSnippet = `npx -y ${NPM_PACKAGE_NAME}@latest mcp`; const defaultEnv = { SENTRY_ACCESS_TOKEN: "sentry-user-token", OPENAI_API_KEY: "your-openai-key", } as const; const coreConfig = { command: "npx", - args: ["@sentry/mcp-server@latest"], + args: ["-y", "sentry@latest", "mcp"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/warp.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/warp.tsx index c557c3fbf..69b6ca7f1 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/warp.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/warp.tsx @@ -83,7 +83,7 @@ export function WarpInstructions({ transport }: WarpInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["@sentry/mcp-server@latest"], + args: ["-y", "sentry@latest", "mcp"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/zed.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/zed.tsx index e67f9cca5..f07957c7c 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/zed.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/zed.tsx @@ -47,7 +47,7 @@ export function ZedInstructions({ transport }: ZedInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["@sentry/mcp-server@latest"], + args: ["-y", "sentry@latest", "mcp"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx index 6a4f0e20d..a7f03ebb1 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx @@ -11,7 +11,7 @@ const orderedSkills = [...skillDefinitions] .sort((a, b) => a.order - b.order); export default function StdioSetup() { - const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`; + const mcpStdioSnippet = `npx -y ${NPM_PACKAGE_NAME}@latest mcp`; const selfHostedHostExample = [ `${mcpStdioSnippet}`, @@ -23,11 +23,12 @@ export default function StdioSetup() { <>

      - The stdio client is made available on npm at{" "} + The stdio server is the sentry mcp command in the{" "} {NPM_PACKAGE_NAME} - - . + {" "} + CLI package. If you have run sentry auth login, it uses + that session and you can skip the token below.

      Note: The MCP is developed against the cloud service @@ -92,7 +93,10 @@ export default function StdioSetup() {

      --access-token / SENTRY_ACCESS_TOKEN
      -
      Required user auth token.
      +
      + User auth token. Optional when you have run{" "} + sentry auth login. +
      --host / SENTRY_HOST @@ -173,9 +177,8 @@ export default function StdioSetup() {

      - Need something else? Run{" "} - npx @sentry/mcp-server@latest --help to view the full - flag list. + Need something else? Run npx -y sentry@latest mcp --help{" "} + to view the full flag list.

      diff --git a/packages/mcp-cloudflare/src/constants.ts b/packages/mcp-cloudflare/src/constants.ts index 69c8d7958..f8895c715 100644 --- a/packages/mcp-cloudflare/src/constants.ts +++ b/packages/mcp-cloudflare/src/constants.ts @@ -1,3 +1,3 @@ export { SCOPES } from "@sentry/mcp-core/scopes"; -export const NPM_PACKAGE_NAME = "@sentry/mcp-server"; +export const NPM_PACKAGE_NAME = "sentry"; diff --git a/packages/mcp-core/README.md b/packages/mcp-core/README.md index 734fee570..60da4828d 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` 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 -y sentry@latest mcp --access-token=sentry-user-token # Limit to specific skills only -npx @sentry/mcp-server@latest --access-token=TOKEN --skills=inspect,docs +npx -y sentry@latest mcp --access-token=TOKEN --skills=inspect,docs # Grant all active, non-deprecated skills, overriding MCP_SKILLS -npx @sentry/mcp-server@latest --access-token=TOKEN --all-skills +npx -y sentry@latest mcp --access-token=TOKEN --all-skills # Self-hosted Sentry -npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com +npx -y sentry@latest mcp --access-token=TOKEN --host=sentry.example.com # 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 -y sentry@latest mcp --access-token=TOKEN --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 -y sentry@latest mcp --access-token=TOKEN --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 @@ -120,10 +120,10 @@ If you're currently using scopes: ```shell # OLD (deprecated) -npx @sentry/mcp-server --access-token=TOKEN --scopes=org:read,event:write +npx -y sentry@latest mcp --access-token=TOKEN --scopes=org:read,event:write # NEW (recommended) -npx @sentry/mcp-server --access-token=TOKEN --skills=inspect,triage +npx -y sentry@latest mcp --access-token=TOKEN --skills=inspect,triage ``` The host configuration accepts two distinct formats: @@ -137,8 +137,8 @@ By default we also enable Sentry reporting (traces, errors) upstream to our clou ```shell # disable sentry reporting -npx @sentry/mcp-server@latest --sentry-dsn= +npx -y sentry@latest mcp --sentry-dsn= # use custom sentry instance -npx @sentry/mcp-server@latest --sentry-dsn=https://publicKey@mysentry.example.com/... +npx -y sentry@latest mcp --sentry-dsn=https://publicKey@mysentry.example.com/... ``` diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 123d00191..0e37b5511 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -140,6 +140,10 @@ "./scopes": { "types": "./dist/scopes.d.ts", "default": "./dist/scopes.js" + }, + "./transports/stdio": { + "types": "./dist/transports/stdio.d.ts", + "default": "./dist/transports/stdio.js" } }, "scripts": { diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index fc8df3d30..3c584894a 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -113,7 +113,7 @@ function structuredOutputToCallToolResult( * @example Usage with stdio transport * ```typescript * import { buildServer } from "@sentry/mcp-core/server"; - * import { startStdio } from "@sentry/mcp-server/transports/stdio"; + * import { startStdio } from "@sentry/mcp-core/transports/stdio"; * * const context = { * accessToken: process.env.SENTRY_TOKEN, diff --git a/packages/mcp-core/src/transports/stdio.ts b/packages/mcp-core/src/transports/stdio.ts new file mode 100644 index 000000000..731501c25 --- /dev/null +++ b/packages/mcp-core/src/transports/stdio.ts @@ -0,0 +1,71 @@ +/** + * Standard I/O transport for the MCP server. + * + * Connects a built server to stdin/stdout so a host process (such as + * `sentry mcp`) can serve MCP over pipes. Host-specific setup — argument + * parsing, credentials, and SDK initialization — stays with the host. + * + * @example + * ```typescript + * import { buildServer } from "@sentry/mcp-core/server"; + * import { startStdio } from "@sentry/mcp-core/transports/stdio"; + * + * const server = buildServer({ context }); + * await startStdio(server, context); + * ``` + */ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { startNewTrace, startSpan } from "@sentry/core"; +import { LIB_VERSION } from "../version"; + +export type StdioServerContext = { + sentryHost?: string; + mcpUrl?: string; + experimentalMode?: boolean; +}; + +function getStdioSpanAttributes( + context: StdioServerContext, +): Record { + const attributes: Record = { + "app.transport": "stdio", + "app.server.version": LIB_VERSION, + "app.server.mode.experimental": context.experimentalMode ?? false, + "network.transport": "pipe", + "service.version": LIB_VERSION, + }; + + if (context.sentryHost) { + attributes["app.upstream.host"] = context.sentryHost; + } + if (context.mcpUrl) { + attributes["app.url.full"] = context.mcpUrl; + } + + return attributes; +} + +/** + * Connect the server to stdio inside a `mcp.server/stdio` span. + * + * Context is already captured in tool handler closures by `buildServer()`; + * it is only read here for telemetry attributes. + */ +export async function startStdio( + server: McpServer, + context: Context, +) { + await startNewTrace(async () => { + return await startSpan( + { + name: "mcp.server/stdio", + attributes: getStdioSpanAttributes(context), + }, + async () => { + const transport = new StdioServerTransport(); + await server.connect(transport); + }, + ); + }); +} diff --git a/packages/mcp-server-evals/README.md b/packages/mcp-server-evals/README.md index 89f73105a..43ac46e1b 100644 --- a/packages/mcp-server-evals/README.md +++ b/packages/mcp-server-evals/README.md @@ -10,7 +10,7 @@ Evaluation helpers and a local mock stdio runner used when developing and valida ### Scopes policy -The mock stdio script grants only the high-level admin scopes that imply all lower permissions via the hierarchy defined in `packages/mcp-server/src/permissions.ts`: +The mock stdio script grants only the high-level admin scopes that imply all lower permissions via the hierarchy defined in `packages/mcp-core/src/permissions.ts`: - `org:admin`, `project:admin`, `team:admin`, `member:admin`, `event:admin` - Plus special non-hierarchical scope: `project:releases` diff --git a/packages/mcp-server-evals/package.json b/packages/mcp-server-evals/package.json index 393716cff..b98e228f0 100644 --- a/packages/mcp-server-evals/package.json +++ b/packages/mcp-server-evals/package.json @@ -19,7 +19,6 @@ "@ai-sdk/openai": "catalog:", "@modelcontextprotocol/sdk": "catalog:", "@sentry/mcp-core": "workspace:*", - "@sentry/mcp-server": "workspace:*", "@sentry/mcp-server-mocks": "workspace:*", "@sentry/mcp-server-tsconfig": "workspace:*", "ai": "catalog:", diff --git a/packages/mcp-server-evals/src/bin/start-mock-stdio.ts b/packages/mcp-server-evals/src/bin/start-mock-stdio.ts index 367afff96..b0b3040af 100644 --- a/packages/mcp-server-evals/src/bin/start-mock-stdio.ts +++ b/packages/mcp-server-evals/src/bin/start-mock-stdio.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { buildServer } from "@sentry/mcp-core/server"; -import { startStdio } from "@sentry/mcp-server/transports/stdio"; +import { startStdio } from "@sentry/mcp-core/transports/stdio"; import { mswServer } from "@sentry/mcp-server-mocks"; import { SKILLS, type Skill } from "@sentry/mcp-core/skills"; import type { ServerContext } from "@sentry/mcp-core/types"; diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..534ae9e8a --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,21 @@ +# @sentry/mcp-server (deprecated) + +The Sentry stdio MCP server now ships in the Sentry CLI. Configure your MCP +client to run: + +```json +{ + "command": "npx", + "args": ["-y", "sentry", "mcp"] +} +``` + +`sentry mcp` accepts the same flags and environment variables, including +`--access-token` / `SENTRY_ACCESS_TOKEN`. Without a token it uses your +`sentry auth login` session. + +This package is a thin shim that prints a deprecation notice and runs +`sentry mcp` with the same arguments. `sentry-mcp auth login|logout|status` +runs `sentry auth login|logout|status`. + +To delete after the final `@sentry/mcp-server` publish. diff --git a/packages/mcp-server/bin/sentry-mcp.js b/packages/mcp-server/bin/sentry-mcp.js new file mode 100644 index 000000000..d6d061c65 --- /dev/null +++ b/packages/mcp-server/bin/sentry-mcp.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { run } from "../lib/shim.js"; + +run(process.argv.slice(2)); diff --git a/packages/mcp-server/lib/shim.js b/packages/mcp-server/lib/shim.js new file mode 100644 index 000000000..b2dd270f9 --- /dev/null +++ b/packages/mcp-server/lib/shim.js @@ -0,0 +1,73 @@ +/** + * Deprecation shim for `@sentry/mcp-server`. + * + * The stdio MCP server now ships in the Sentry CLI as `sentry mcp`. This + * package forwards every invocation to the `sentry` package so existing MCP + * client configurations keep working for one last release. + * + * To delete after the final `@sentry/mcp-server` publish. + */ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { constants } from "node:os"; +import { dirname, join } from "node:path"; + +export const DEPRECATION_NOTICE = + "@sentry/mcp-server is deprecated and now runs `sentry mcp`; update your MCP client to run `npx -y sentry mcp`."; + +/** + * Map `@sentry/mcp-server` arguments to `sentry` arguments. + * + * Server flags pass through to `sentry mcp` unchanged. The old `auth` + * subcommands map to the CLI's own `sentry auth` commands; bare `auth` meant + * `auth login`. + * + * @param {readonly string[]} args + * @returns {string[]} + */ +export function toSentryArgs(args) { + if (args[0] !== "auth") { + return ["mcp", ...args]; + } + const rest = args.slice(1); + return ["auth", ...(rest.length > 0 ? rest : ["login"])]; +} + +/** + * Locate the `sentry` package's bin script next to its main entry. + * + * @param {(id: string) => string} [resolve] + * @returns {string} + */ +export function resolveSentryBin( + resolve = createRequire(import.meta.url).resolve, +) { + return join(dirname(resolve("sentry")), "bin.cjs"); +} + +/** + * Run `sentry` with the mapped arguments and mirror its exit status. + * + * @param {readonly string[]} args + */ +export function run(args) { + process.stderr.write(`${DEPRECATION_NOTICE}\n`); + + const child = spawn( + process.execPath, + [resolveSentryBin(), ...toSentryArgs(args)], + { + stdio: "inherit", + }, + ); + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); + } + child.on("error", (error) => { + process.stderr.write(`Failed to start sentry: ${error.message}\n`); + process.exit(1); + }); + child.on("exit", (code, signal) => { + process.exit(code ?? 128 + (signal ? (constants.signals[signal] ?? 0) : 0)); + }); +} diff --git a/packages/mcp-server/lib/shim.test.js b/packages/mcp-server/lib/shim.test.js new file mode 100644 index 000000000..d5885c671 --- /dev/null +++ b/packages/mcp-server/lib/shim.test.js @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { resolveSentryBin, toSentryArgs } from "./shim.js"; + +describe("toSentryArgs", () => { + it("forwards server flags to `sentry mcp` unchanged", () => { + assert.deepEqual( + toSentryArgs(["--access-token=TOKEN", "--host", "sentry.example.com"]), + ["mcp", "--access-token=TOKEN", "--host", "sentry.example.com"], + ); + assert.deepEqual(toSentryArgs([]), ["mcp"]); + }); + + it("maps the old auth subcommands to `sentry auth`", () => { + assert.deepEqual(toSentryArgs(["auth", "login"]), ["auth", "login"]); + assert.deepEqual(toSentryArgs(["auth", "logout"]), ["auth", "logout"]); + assert.deepEqual(toSentryArgs(["auth", "status"]), ["auth", "status"]); + }); + + it("treats bare `auth` as `auth login`", () => { + assert.deepEqual(toSentryArgs(["auth"]), ["auth", "login"]); + }); +}); + +describe("resolveSentryBin", () => { + it("uses the bin script next to the sentry package entry", () => { + const entry = join("/deps", "sentry", "dist", "index.cjs"); + assert.equal( + resolveSentryBin((id) => { + assert.equal(id, "sentry"); + return entry; + }), + join("/deps", "sentry", "dist", "bin.cjs"), + ); + }); +}); diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index a54ee8bed..cce172856 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,7 +1,10 @@ { "name": "@sentry/mcp-server", - "mcpName": "io.github.getsentry/sentry-mcp", "version": "0.42.0", + "description": "Deprecated: use `sentry mcp` from the `sentry` package instead", + "sentryCi": { + "enabled": false + }, "type": "module", "packageManager": "pnpm@11.8.0", "engines": { @@ -12,60 +15,30 @@ }, "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": "./bin/sentry-mcp.js" }, "files": [ - "./dist/*" + "bin/sentry-mcp.js", + "lib/shim.js" ], - "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" + "test": "node --test lib/shim.test.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "catalog:", - "@sentry/node": "catalog:", - "@sentry/core": "catalog:", - "dotenv": "catalog:", - "zod": "catalog:" - }, - "devDependencies": { - "@sentry/mcp-core": "workspace:*", - "@sentry/mcp-server-mocks": "workspace:*", - "@sentry/mcp-server-tsconfig": "workspace:*", - "@sentry/toolkit-core": "workspace:*", - "@types/node": "catalog:", - "tsdown": "catalog:", - "tsx": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" + "sentry": "workspace:>=0.48.0-dev.0" } } diff --git a/packages/mcp-server/src/auth/constants.ts b/packages/mcp-server/src/auth/constants.ts deleted file mode 100644 index 4655b4341..000000000 --- a/packages/mcp-server/src/auth/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SCOPES } from "@sentry/mcp-core/scopes"; - -/** - * Public OAuth app registered on sentry.io for the stdio device code flow. - * No client secret — device code grant is a public client flow. - * The Cloudflare transport uses a separate OAuth app with a secret. - * Override via SENTRY_CLIENT_ID environment variable. - * See docs/operations/stdio-auth.md for the full credential architecture. - */ -export const DEFAULT_SENTRY_CLIENT_ID = - "0acbeba7d07d58076dd7dbde8cea2fed8ab525ce3713bda604988009ab35d765"; - -export const DEVICE_CODE_ENDPOINT = "/oauth/device/code/"; -export const TOKEN_ENDPOINT = "/oauth/token/"; -export const DEVICE_CODE_SCOPES = Object.keys(SCOPES).join(" "); - -/** - * OAuth endpoints live on sentry.io regardless of regional host. - * Always use this as the host for device code and token requests. - */ -export const OAUTH_HOST = "sentry.io"; diff --git a/packages/mcp-server/src/auth/device-code-flow.test.ts b/packages/mcp-server/src/auth/device-code-flow.test.ts deleted file mode 100644 index 61d63c266..000000000 --- a/packages/mcp-server/src/auth/device-code-flow.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - requestDeviceCode, - pollForToken, - DeviceCodeError, -} from "./device-code-flow"; - -const mockDeviceCodeResponse = { - device_code: "test-device-code", - user_code: "ABCD-1234", - verification_uri: "https://sentry.io/oauth/device", - verification_uri_complete: - "https://sentry.io/oauth/device?user_code=ABCD-1234", - interval: 1, - expires_in: 600, -}; - -const mockTokenResponse = { - access_token: "sntrys_test_access_token", - refresh_token: "sntrys_test_refresh_token", - token_type: "bearer", - expires_in: 2592000, - expires_at: new Date(Date.now() + 2592000 * 1000).toISOString(), - user: { - email: "test@example.com", - id: "12345", - name: "Test User", - }, - scope: "org:read project:write team:write event:write", -}; - -describe("requestDeviceCode", () => { - beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("sends correct request and parses response", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify(mockDeviceCodeResponse), { status: 200 }), - ); - - const result = await requestDeviceCode( - "test-client-id", - "sentry.io", - "org:read", - ); - - expect(fetch).toHaveBeenCalledWith( - "https://sentry.io/oauth/device/code/", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/x-www-form-urlencoded", - }), - }), - ); - expect(result.device_code).toBe("test-device-code"); - expect(result.user_code).toBe("ABCD-1234"); - }); - - it("throws on non-ok response", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response("Bad Request", { status: 400 }), - ); - - await expect(requestDeviceCode("bad-client", "sentry.io")).rejects.toThrow( - DeviceCodeError, - ); - }); -}); - -describe("pollForToken", () => { - beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns token on immediate success", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify(mockTokenResponse), { status: 200 }), - ); - - const result = await pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, // Fast polling for tests - expiresIn: 10, - }); - - expect(result.access_token).toBe("sntrys_test_access_token"); - expect(result.user.email).toBe("test@example.com"); - }); - - it("retries on authorization_pending", async () => { - vi.mocked(fetch) - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "authorization_pending" }), { - status: 400, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(mockTokenResponse), { status: 200 }), - ); - - const result = await pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, - expiresIn: 10, - }); - - expect(fetch).toHaveBeenCalledTimes(2); - expect(result.access_token).toBe("sntrys_test_access_token"); - }); - - it("throws on access_denied", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ error: "access_denied" }), { status: 400 }), - ); - - await expect( - pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, - expiresIn: 10, - }), - ).rejects.toThrow(/Authorization was denied/); - }); - - it("throws on expired_token", async () => { - vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ error: "expired_token" }), { status: 400 }), - ); - - await expect( - pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, - expiresIn: 10, - }), - ).rejects.toThrow(/expired/); - }); - - it("reports an unexpected token error without retrying", async () => { - const fetchMock = vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ error: "invalid_grant" }), { - status: 400, - }), - ); - - await expect( - pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 0.01, - expiresIn: 10, - }), - ).rejects.toMatchObject({ - name: "DeviceCodeError", - code: "invalid_grant", - message: "Unexpected error during device code polling: invalid_grant", - }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("increases interval on slow_down", async () => { - vi.useFakeTimers(); - const fetchMock = vi.mocked(fetch); - fetchMock - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "slow_down" }), { status: 400 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(mockTokenResponse), { status: 200 }), - ); - - const promise = pollForToken({ - deviceCode: "test-device-code", - clientId: "test-client-id", - host: "sentry.io", - interval: 1, - expiresIn: 30, - }); - - // First poll after 1s interval - await vi.advanceTimersByTimeAsync(1000); - // After slow_down, interval becomes 1 + 5 = 6s - await vi.advanceTimersByTimeAsync(6000); - - const result = await promise; - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(result.access_token).toBe("sntrys_test_access_token"); - vi.useRealTimers(); - }); -}); diff --git a/packages/mcp-server/src/auth/device-code-flow.ts b/packages/mcp-server/src/auth/device-code-flow.ts deleted file mode 100644 index c18bb21a7..000000000 --- a/packages/mcp-server/src/auth/device-code-flow.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { exec } from "node:child_process"; -import * as fs from "node:fs"; -import { LIB_VERSION } from "@sentry/mcp-core/version"; -import { - deviceCodeRequestBody, - deviceTokenRequestBody, -} from "@sentry/toolkit-core/oauth-device"; -import { advanceDevicePoll } from "@sentry/toolkit-core/oauth-poll"; -import { - DEVICE_CODE_ENDPOINT, - DEVICE_CODE_SCOPES, - TOKEN_ENDPOINT, -} from "./constants"; -import { - DeviceCodeErrorSchema, - type DeviceCodeResponse, - DeviceCodeResponseSchema, - getTokenUserLabel, - type TokenResponse, - TokenResponseSchema, -} from "./types"; - -const USER_AGENT = `sentry-mcp-server/${LIB_VERSION}`; - -export class DeviceCodeError extends Error { - constructor( - message: string, - public readonly code?: string, - ) { - super(message); - this.name = "DeviceCodeError"; - } -} - -export async function requestDeviceCode( - clientId: string, - host: string, - scopes: string = DEVICE_CODE_SCOPES, -): Promise { - const url = `https://${host}${DEVICE_CODE_ENDPOINT}`; - const resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": USER_AGENT, - }, - body: deviceCodeRequestBody(clientId, scopes), - }); - - if (!resp.ok) { - const text = await resp.text(); - throw new DeviceCodeError( - `Failed to request device code (HTTP ${resp.status}): ${text}`, - ); - } - - const body = await resp.json(); - return DeviceCodeResponseSchema.parse(body); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function pollForToken({ - deviceCode, - clientId, - host, - interval, - expiresIn, -}: { - deviceCode: string; - clientId: string; - host: string; - interval: number; - expiresIn: number; -}): Promise { - const url = `https://${host}${TOKEN_ENDPOINT}`; - const deadline = Date.now() + expiresIn * 1000; - let pollInterval = interval; - - while (Date.now() < deadline) { - await sleep(pollInterval * 1000); - - const resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": USER_AGENT, - }, - body: deviceTokenRequestBody(clientId, deviceCode), - }); - - if (resp.ok) { - const body = await resp.json(); - return TokenResponseSchema.parse(body); - } - - const errorBody = await resp.json().catch(() => null); - const parsed = DeviceCodeErrorSchema.safeParse(errorBody); - const errorCode = parsed.success ? parsed.data.error : undefined; - - const outcome = advanceDevicePoll(pollInterval, errorCode); - switch (outcome.status) { - case "retry": - pollInterval = outcome.intervalSeconds; - continue; - case "denied": - throw new DeviceCodeError( - "Authorization was denied. Please try again or provide --access-token.", - errorCode, - ); - case "expired": - throw new DeviceCodeError( - "Device code expired before authorization was completed.", - errorCode, - ); - case "unexpected": - throw new DeviceCodeError( - `Unexpected error during device code polling: ${errorCode ?? resp.statusText}`, - errorCode, - ); - } - } - - throw new DeviceCodeError( - "Device code expired before authorization was completed.", - "expired_token", - ); -} - -/** - * Write directly to stderr, bypassing console.warn which may be - * intercepted by the MCP transport layer. - */ -function stderr(msg: string): void { - process.stderr.write(`${msg}\n`); -} - -function isWSL(): boolean { - try { - return fs - .readFileSync("/proc/version", "utf-8") - .toLowerCase() - .includes("microsoft"); - } catch { - return false; - } -} - -function openBrowser(url: string): void { - try { - const { platform } = process; - if (platform === "darwin") { - exec(`open ${JSON.stringify(url)}`); - } else if (platform === "win32") { - exec(`start "" ${JSON.stringify(url)}`); - } else if (isWSL()) { - // WSL: invoke cmd.exe to open the default browser. - exec(`cmd.exe /c start "" ${JSON.stringify(url)}`); - } else { - exec(`xdg-open ${JSON.stringify(url)}`); - } - } catch { - // Best-effort — ignore errors (headless, etc.) - } -} - -export function displayDeviceCode(response: DeviceCodeResponse): void { - stderr(""); - stderr(`To authorize, visit: ${response.verification_uri_complete}`); - stderr(` and enter code: ${response.user_code}`); - stderr(""); - stderr("Waiting for authorization..."); - - openBrowser(response.verification_uri_complete); -} - -export async function authenticate({ - clientId, - host, -}: { - clientId: string; - host: string; -}): Promise { - stderr("No access token provided. Starting device authorization..."); - - const deviceCodeResponse = await requestDeviceCode(clientId, host); - displayDeviceCode(deviceCodeResponse); - - const tokenResponse = await pollForToken({ - deviceCode: deviceCodeResponse.device_code, - clientId, - host, - interval: deviceCodeResponse.interval, - expiresIn: deviceCodeResponse.expires_in, - }); - - stderr(`Successfully authenticated as ${getTokenUserLabel(tokenResponse)}`); - return tokenResponse; -} diff --git a/packages/mcp-server/src/auth/resolve-token.test.ts b/packages/mcp-server/src/auth/resolve-token.test.ts deleted file mode 100644 index 0f1debb20..000000000 --- a/packages/mcp-server/src/auth/resolve-token.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { resolveAccessToken } from "./resolve-token"; -import type { PartiallyResolvedConfig } from "../cli/types"; - -// Mock the auth modules so we never make real network calls or touch disk -vi.mock("./device-code-flow", () => ({ - authenticate: vi.fn(), -})); - -vi.mock("./token-cache", () => ({ - readCachedToken: vi.fn().mockResolvedValue(null), - writeCachedToken: vi.fn().mockResolvedValue(undefined), -})); - -function makePartialConfig( - overrides: Partial = {}, -): PartiallyResolvedConfig { - return { - clientId: "test-client-id", - sentryHost: "sentry.io", - finalSkills: new Set(), - ...overrides, - }; -} - -describe("resolveAccessToken", () => { - let originalIsTTY: boolean | undefined; - - beforeEach(() => { - originalIsTTY = process.stderr.isTTY; - }); - - afterEach(() => { - Object.defineProperty(process.stderr, "isTTY", { - value: originalIsTTY, - writable: true, - configurable: true, - }); - vi.restoreAllMocks(); - }); - - it("returns immediately when accessToken is provided", async () => { - const cfg = makePartialConfig({ accessToken: "existing-token" }); - const result = await resolveAccessToken(cfg); - expect(result.accessToken).toBe("existing-token"); - }); - - it("throws for non-sentry.io hosts without a token", async () => { - const cfg = makePartialConfig({ - sentryHost: "sentry.example.com", - }); - await expect(resolveAccessToken(cfg)).rejects.toThrow( - /only supported for sentry.io/, - ); - }); - - it("throws in non-TTY context when no token and no cache", async () => { - Object.defineProperty(process.stderr, "isTTY", { - value: undefined, - writable: true, - configurable: true, - }); - - const cfg = makePartialConfig(); - await expect(resolveAccessToken(cfg)).rejects.toThrow( - /Run `sentry-mcp auth login` interactively first/, - ); - }); - - it("uses cached token in non-TTY context", async () => { - Object.defineProperty(process.stderr, "isTTY", { - value: undefined, - writable: true, - configurable: true, - }); - - const { readCachedToken } = await import("./token-cache"); - vi.mocked(readCachedToken).mockResolvedValueOnce({ - access_token: "cached-token", - refresh_token: "refresh", - expires_at: new Date(Date.now() + 86400000).toISOString(), - sentry_host: "sentry.io", - client_id: "test-client-id", - user_email: "test@example.com", - scope: "org:read", - }); - - const cfg = makePartialConfig(); - const result = await resolveAccessToken(cfg); - expect(result.accessToken).toBe("cached-token"); - }); - - it("triggers device code flow in TTY context when no cache", async () => { - Object.defineProperty(process.stderr, "isTTY", { - value: true, - writable: true, - configurable: true, - }); - - const { authenticate } = await import("./device-code-flow"); - vi.mocked(authenticate).mockResolvedValueOnce({ - access_token: "new-token", - refresh_token: "refresh", - token_type: "bearer", - expires_in: 2592000, - expires_at: new Date(Date.now() + 2592000000).toISOString(), - user: { email: "test@example.com", id: "1", name: "Test" }, - scope: "org:read", - }); - - const cfg = makePartialConfig(); - const result = await resolveAccessToken(cfg); - expect(result.accessToken).toBe("new-token"); - expect(authenticate).toHaveBeenCalledWith({ - clientId: "test-client-id", - host: "sentry.io", - }); - }); - - it("accepts regional sentry.io subdomains", async () => { - Object.defineProperty(process.stderr, "isTTY", { - value: true, - writable: true, - configurable: true, - }); - - const { authenticate } = await import("./device-code-flow"); - vi.mocked(authenticate).mockResolvedValueOnce({ - access_token: "regional-token", - refresh_token: "refresh", - token_type: "bearer", - expires_in: 2592000, - expires_at: new Date(Date.now() + 2592000000).toISOString(), - user: { email: "test@example.com", id: "1", name: "Test" }, - scope: "org:read", - }); - - const cfg = makePartialConfig({ sentryHost: "us.sentry.io" }); - const result = await resolveAccessToken(cfg); - expect(result.accessToken).toBe("regional-token"); - // OAuth endpoints always use sentry.io, not the regional subdomain - expect(authenticate).toHaveBeenCalledWith({ - clientId: "test-client-id", - host: "sentry.io", - }); - }); -}); diff --git a/packages/mcp-server/src/auth/resolve-token.ts b/packages/mcp-server/src/auth/resolve-token.ts deleted file mode 100644 index fbb3c72a0..000000000 --- a/packages/mcp-server/src/auth/resolve-token.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; -import type { PartiallyResolvedConfig, ResolvedConfig } from "../cli/types"; -import { OAUTH_HOST } from "./constants"; -import { authenticate } from "./device-code-flow"; -import { readCachedToken, writeCachedToken } from "./token-cache"; -import { toCachedToken } from "./types"; - -function withToken( - partial: PartiallyResolvedConfig, - accessToken: string, -): ResolvedConfig { - return { ...partial, accessToken }; -} - -/** - * Resolves the access token for the session. - * - * If an access token is already provided, returns immediately. - * If the host is sentry.io, attempts to use a cached token or - * initiates the device code flow (only when stderr is a TTY). - * For non-sentry.io hosts, throws an error requiring --access-token. - */ -export async function resolveAccessToken( - partial: PartiallyResolvedConfig, -): Promise { - if (partial.accessToken) { - return withToken(partial, partial.accessToken); - } - - if (!isSentryHost(partial.sentryHost)) { - throw new Error( - "Error: No access token was provided. Device code authentication is only supported for sentry.io.\n" + - "Pass one with `--access-token` or via `SENTRY_ACCESS_TOKEN`.", - ); - } - - const { clientId, sentryHost } = partial; - - // Try cached token first (works in both interactive and non-interactive contexts) - try { - const cached = await readCachedToken(sentryHost, clientId); - if (cached) { - process.stderr.write( - `Using cached authentication for ${cached.user_email}\n`, - ); - return withToken(partial, cached.access_token); - } - } catch { - // Cache read failure is non-fatal - } - - // Device code flow requires a human to visit a URL and authorize. - // In non-interactive contexts (CI, piped stdio), fail immediately - // instead of hanging on the polling loop until expiry. - if (!process.stderr.isTTY) { - throw new Error( - "Error: No access token was provided.\n" + - "Run `sentry-mcp auth login` interactively first, or pass `--access-token` / `SENTRY_ACCESS_TOKEN`.", - ); - } - - const tokenResponse = await authenticate({ clientId, host: OAUTH_HOST }); - - try { - await writeCachedToken(toCachedToken(tokenResponse, sentryHost, clientId)); - } catch { - process.stderr.write("Warning: Could not cache authentication token.\n"); - } - - return withToken(partial, tokenResponse.access_token); -} diff --git a/packages/mcp-server/src/auth/token-cache.test.ts b/packages/mcp-server/src/auth/token-cache.test.ts deleted file mode 100644 index b1cd3ed3b..000000000 --- a/packages/mcp-server/src/auth/token-cache.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import * as os from "node:os"; -import { - readCachedToken, - writeCachedToken, - clearCachedToken, -} from "./token-cache"; -import type { CachedToken } from "./types"; - -let tmpDir: string; - -function makeCachedToken(overrides: Partial = {}): CachedToken { - return { - access_token: "sntrys_test_token", - refresh_token: "sntrys_refresh_token", - expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - sentry_host: "sentry.io", - client_id: "test-client-id", - user_email: "user@example.com", - scope: "org:read project:write team:write event:write", - ...overrides, - }; -} - -beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-auth-test-")); - process.env.SENTRY_MCP_AUTH_CACHE = path.join(tmpDir, "mcp.json"); -}); - -afterEach(async () => { - process.env.SENTRY_MCP_AUTH_CACHE = undefined; - await fs.rm(tmpDir, { recursive: true, force: true }); -}); - -describe("token-cache", () => { - it("returns null when no cache file exists", async () => { - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toBeNull(); - }); - - it("writes and reads a token", async () => { - const token = makeCachedToken(); - await writeCachedToken(token); - - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toEqual(token); - }); - - it("returns null for a different host/clientId pair", async () => { - await writeCachedToken(makeCachedToken()); - - const result = await readCachedToken("other.sentry.io", "test-client-id"); - expect(result).toBeNull(); - }); - - it("returns null and clears expired tokens", async () => { - const expired = makeCachedToken({ - expires_at: new Date(Date.now() - 1000).toISOString(), - }); - await writeCachedToken(expired); - - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toBeNull(); - - // Verify it was cleared from the file - const raw = await fs.readFile(process.env.SENTRY_MCP_AUTH_CACHE!, "utf-8"); - const data = JSON.parse(raw); - expect(data["sentry.io:test-client-id"]).toBeUndefined(); - }); - - it("returns null for tokens expiring within safety window", async () => { - const almostExpired = makeCachedToken({ - // Expires in 2 minutes (within 5-minute safety window) - expires_at: new Date(Date.now() + 2 * 60 * 1000).toISOString(), - }); - await writeCachedToken(almostExpired); - - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toBeNull(); - }); - - it("clears a specific token", async () => { - await writeCachedToken(makeCachedToken()); - await writeCachedToken( - makeCachedToken({ - sentry_host: "other.sentry.io", - client_id: "other-id", - }), - ); - - await clearCachedToken("sentry.io", "test-client-id"); - - const cleared = await readCachedToken("sentry.io", "test-client-id"); - expect(cleared).toBeNull(); - - // Other entry should still exist - const other = await readCachedToken("other.sentry.io", "other-id"); - expect(other).not.toBeNull(); - }); - - it("handles corrupted cache file gracefully", async () => { - await fs.writeFile(process.env.SENTRY_MCP_AUTH_CACHE!, "not-json", "utf-8"); - - const result = await readCachedToken("sentry.io", "test-client-id"); - expect(result).toBeNull(); - }); - - it("sets restrictive file permissions", async () => { - await writeCachedToken(makeCachedToken()); - - const stat = await fs.stat(process.env.SENTRY_MCP_AUTH_CACHE!); - // 0o600 = owner read/write only (on Unix-like systems) - const mode = stat.mode & 0o777; - expect(mode).toBe(0o600); - }); -}); diff --git a/packages/mcp-server/src/auth/token-cache.ts b/packages/mcp-server/src/auth/token-cache.ts deleted file mode 100644 index beaca5bbf..000000000 --- a/packages/mcp-server/src/auth/token-cache.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Persistent token cache for device code auth. - * Tokens are stored at ~/.sentry/mcp.json, keyed by {host}:{clientId}. - * See docs/operations/stdio-auth.md for cache format and security details. - */ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import * as os from "node:os"; -import type { CachedToken } from "./types"; - -/** Safety window: treat tokens as expired 5 minutes before actual expiry. */ -const EXPIRY_SAFETY_MS = 5 * 60 * 1000; - -function getCacheFilePath(): string { - if (process.env.SENTRY_MCP_AUTH_CACHE) { - return process.env.SENTRY_MCP_AUTH_CACHE; - } - return path.join(os.homedir(), ".sentry", "mcp.json"); -} - -function cacheKey(host: string, clientId: string): string { - return `${host}:${clientId}`; -} - -type CacheFile = Record; - -async function readCacheFile(): Promise { - try { - const raw = await fs.readFile(getCacheFilePath(), "utf-8"); - return JSON.parse(raw) as CacheFile; - } catch { - return {}; - } -} - -async function writeCacheFile(data: CacheFile): Promise { - const filePath = getCacheFilePath(); - const dir = path.dirname(filePath); - - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - - // Atomic write: write to temp file then rename - const tmpPath = `${filePath}.tmp.${process.pid}`; - await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), { - encoding: "utf-8", - mode: 0o600, - }); - await fs.rename(tmpPath, filePath); -} - -export async function readCachedToken( - host: string, - clientId: string, -): Promise { - const data = await readCacheFile(); - const key = cacheKey(host, clientId); - const entry = data[key]; - if (!entry) return null; - - const expiresAt = new Date(entry.expires_at).getTime(); - if ( - !Number.isFinite(expiresAt) || - expiresAt - Date.now() <= EXPIRY_SAFETY_MS - ) { - delete data[key]; - await writeCacheFile(data); - return null; - } - - return entry; -} - -export async function writeCachedToken(token: CachedToken): Promise { - const data = await readCacheFile(); - data[cacheKey(token.sentry_host, token.client_id)] = token; - await writeCacheFile(data); -} - -export async function clearCachedToken( - host: string, - clientId: string, -): Promise { - const data = await readCacheFile(); - const key = cacheKey(host, clientId); - if (key in data) { - delete data[key]; - await writeCacheFile(data); - } -} diff --git a/packages/mcp-server/src/auth/types.test.ts b/packages/mcp-server/src/auth/types.test.ts deleted file mode 100644 index fd0c26001..000000000 --- a/packages/mcp-server/src/auth/types.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { TokenResponseSchema, toCachedToken } from "./types"; - -describe("TokenResponseSchema", () => { - it("accepts upstream token payloads without email validation", () => { - const token = TokenResponseSchema.parse({ - access_token: "access-token", - refresh_token: "refresh-token", - token_type: "bearer", - expires_in: 3600, - expires_at: "2026-04-13T13:00:00.000Z", - user: { - email: "github-sso-user", - id: "123", - name: null, - }, - scope: "org:read", - }); - - expect(token.refresh_token).toBe("refresh-token"); - expect(token.expires_in).toBe(3600); - expect(token.expires_at).toBe("2026-04-13T13:00:00.000Z"); - expect(token.user.email).toBe("github-sso-user"); - }); -}); - -describe("toCachedToken", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("stores the upstream absolute expiry and a fallback user label", () => { - const cached = toCachedToken( - { - access_token: "access-token", - refresh_token: "refresh-token", - token_type: "bearer", - expires_in: 3600, - expires_at: "2026-04-13T13:00:00.000Z", - user: { - email: "github-sso-user", - id: "123", - name: null, - }, - scope: "org:read", - }, - "sentry.io", - "client-id", - ); - - expect(cached).toEqual({ - access_token: "access-token", - refresh_token: "refresh-token", - expires_at: "2026-04-13T13:00:00.000Z", - sentry_host: "sentry.io", - client_id: "client-id", - user_email: "github-sso-user", - scope: "org:read", - }); - }); -}); diff --git a/packages/mcp-server/src/auth/types.ts b/packages/mcp-server/src/auth/types.ts deleted file mode 100644 index ce5ec4911..000000000 --- a/packages/mcp-server/src/auth/types.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from "zod"; - -/** - * Response from Sentry's device code endpoint (POST /oauth/device/code/). - * See: https://docs.sentry.io/api/auth/#device-authorization-flow - */ -export const DeviceCodeResponseSchema = z.object({ - device_code: z.string(), - user_code: z.string(), - verification_uri: z.string().url(), - verification_uri_complete: z.string().url(), - interval: z.number().int().positive(), - expires_in: z.number().int().positive(), -}); - -export type DeviceCodeResponse = z.infer; - -/** - * Error response during device code token polling. - */ -export const DeviceCodeErrorSchema = z.object({ - error: z.string(), - error_description: z.string().optional(), -}); - -/** - * Successful token response from Sentry's token endpoint. - * Mirrors the shape used by the cloudflare OAuth callback. - */ -export const TokenResponseSchema = z.object({ - access_token: z.string(), - refresh_token: z.string(), - token_type: z.string(), - expires_in: z.number(), - expires_at: z.string().datetime(), - user: z.object({ - email: z.string().nullable().optional(), - id: z.string(), - name: z.string().nullable(), - }), - scope: z.string(), -}); - -export type TokenResponse = z.infer; - -/** - * Shape of a cached token entry stored on disk. - */ -export type CachedToken = { - access_token: string; - refresh_token: string; - expires_at: string; - sentry_host: string; - client_id: string; - // Historical name retained for cache compatibility; this stores a user label, - // not necessarily an email address. - user_email: string; - scope: string; -}; - -export function getTokenUserLabel(tokenResponse: TokenResponse): string { - return ( - tokenResponse.user.name ?? tokenResponse.user.email ?? tokenResponse.user.id - ); -} - -export function toCachedToken( - tokenResponse: TokenResponse, - sentryHost: string, - clientId: string, -): CachedToken { - return { - access_token: tokenResponse.access_token, - refresh_token: tokenResponse.refresh_token, - expires_at: tokenResponse.expires_at, - sentry_host: sentryHost, - client_id: clientId, - user_email: getTokenUserLabel(tokenResponse), - scope: tokenResponse.scope, - }; -} diff --git a/packages/mcp-server/src/cli/commands/auth.test.ts b/packages/mcp-server/src/cli/commands/auth.test.ts deleted file mode 100644 index e29a99de4..000000000 --- a/packages/mcp-server/src/cli/commands/auth.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { parseFlag } from "./auth"; - -describe("parseFlag", () => { - it("parses --flag=value form", () => { - expect(parseFlag(["--host=sentry.example.com"], "host")).toBe( - "sentry.example.com", - ); - }); - - it("parses --flag value form", () => { - expect(parseFlag(["--host", "sentry.example.com"], "host")).toBe( - "sentry.example.com", - ); - }); - - it("returns undefined when flag is absent", () => { - expect(parseFlag(["--other=foo"], "host")).toBeUndefined(); - }); - - it("returns undefined when --flag is last arg with no value", () => { - expect(parseFlag(["--host"], "host")).toBeUndefined(); - }); - - it("prefers first occurrence", () => { - expect(parseFlag(["--host=first", "--host=second"], "host")).toBe("first"); - }); - - it("works with --url flag", () => { - expect(parseFlag(["--url", "https://sentry.example.com"], "url")).toBe( - "https://sentry.example.com", - ); - }); - - it("does not match partial flag names", () => { - expect(parseFlag(["--hostname=foo"], "host")).toBeUndefined(); - }); -}); diff --git a/packages/mcp-server/src/cli/commands/auth.ts b/packages/mcp-server/src/cli/commands/auth.ts deleted file mode 100644 index e69b9cf33..000000000 --- a/packages/mcp-server/src/cli/commands/auth.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; -import { parseEnv } from "../parse"; -import { resolveHost } from "../resolve"; -import { DEFAULT_SENTRY_CLIENT_ID, OAUTH_HOST } from "../../auth/constants"; -import { authenticate } from "../../auth/device-code-flow"; -import { - readCachedToken, - writeCachedToken, - clearCachedToken, -} from "../../auth/token-cache"; -import { toCachedToken } from "../../auth/types"; - -type AuthContext = { - sentryHost: string; - clientId: string; -}; - -export function parseFlag(argv: string[], name: string): string | undefined { - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg.startsWith(`--${name}=`)) { - return arg.slice(`--${name}=`.length); - } - if (arg === `--${name}` && i + 1 < argv.length) { - return argv[i + 1]; - } - } - return undefined; -} - -function resolveAuthContext(argv: string[]): AuthContext { - const env = parseEnv(process.env); - - // Match the precedence of the server's merge() + finalize() path: - // merged url (CLI --url ?? env SENTRY_URL) beats merged host (CLI --host ?? env SENTRY_HOST) - const url = parseFlag(argv, "url") ?? env.url; - const host = parseFlag(argv, "host") ?? env.host; - const sentryHost = resolveHost(url, host); - - return { sentryHost, clientId: env.clientId || DEFAULT_SENTRY_CLIENT_ID }; -} - -async function login(argv: string[]): Promise { - const { sentryHost, clientId } = resolveAuthContext(argv); - - if (!isSentryHost(sentryHost)) { - console.error( - "Error: Device code authentication is only supported for sentry.io.", - ); - process.exit(1); - } - - try { - const tokenResponse = await authenticate({ clientId, host: OAUTH_HOST }); - await writeCachedToken(toCachedToken(tokenResponse, sentryHost, clientId)); - } catch (err) { - console.error( - err instanceof Error - ? err.message - : `Authentication failed: ${String(err)}`, - ); - process.exit(1); - } -} - -async function logout(argv: string[]): Promise { - const { sentryHost, clientId } = resolveAuthContext(argv); - - const cached = await readCachedToken(sentryHost, clientId); - if (cached) { - await clearCachedToken(sentryHost, clientId); - console.log(`Logged out (removed cached token for ${cached.user_email}).`); - } else { - console.log("No cached authentication found."); - } -} - -async function status(argv: string[]): Promise { - const { sentryHost, clientId } = resolveAuthContext(argv); - - const cached = await readCachedToken(sentryHost, clientId); - if (cached) { - const expiresAt = new Date(cached.expires_at); - console.log(`Authenticated as ${cached.user_email}`); - console.log(` Host: ${cached.sentry_host}`); - console.log(` Scopes: ${cached.scope}`); - console.log(` Expires: ${expiresAt.toLocaleString()}`); - } else { - console.log("Not authenticated. Run `sentry-mcp auth login` to sign in."); - } -} - -export async function authCommand(argv: string[]): Promise { - const sub = argv[0] ?? "login"; - const rest = argv.slice(1); - - switch (sub) { - case "login": - return await login(rest); - case "logout": - return await logout(rest); - case "status": - return await status(rest); - default: - console.error(`Unknown auth command: ${sub}`); - console.error("Available: auth login, auth logout, auth status"); - process.exit(1); - } -} diff --git a/packages/mcp-server/src/cli/output.ts b/packages/mcp-server/src/cli/output.ts deleted file mode 100644 index bdb71d834..000000000 --- a/packages/mcp-server/src/cli/output.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function printCliLine(message: string): void { - console.log(message); -} diff --git a/packages/mcp-server/src/cli/parse.test.ts b/packages/mcp-server/src/cli/parse.test.ts deleted file mode 100644 index dfe8985a5..000000000 --- a/packages/mcp-server/src/cli/parse.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { parseArgv, parseEnv, merge } from "./parse"; - -describe("cli/parseArgv", () => { - it("parses known flags and short aliases", () => { - const parsed = parseArgv([ - "--access-token=tok", - "--host=sentry.io", - "--insecure-http", - "--url=https://example.com", - "--mcp-url=https://mcp.example.com", - "--sentry-dsn=dsn", - "--openai-base-url=https://api.example.com/v1", - "--skills=inspect,triage", - "--all-skills", - "-h", - "-v", - ]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.host).toBe("sentry.io"); - expect(parsed.insecureHttp).toBe(true); - expect(parsed.url).toBe("https://example.com"); - expect(parsed.mcpUrl).toBe("https://mcp.example.com"); - expect(parsed.sentryDsn).toBe("dsn"); - expect(parsed.openaiBaseUrl).toBe("https://api.example.com/v1"); - expect(parsed.skills).toBe("inspect,triage"); - expect(parsed.allSkills).toBe(true); - expect(parsed.help).toBe(true); - expect(parsed.version).toBe(true); - expect(parsed.unknownArgs).toEqual([]); - }); - - it("parses skills flags", () => { - const parsed = parseArgv(["--access-token=tok", "--skills=inspect,triage"]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.skills).toBe("inspect,triage"); - }); - - it("parses --disable-skills", () => { - const parsed = parseArgv(["--access-token=tok", "--disable-skills=seer"]); - expect(parsed.disableSkills).toBe("seer"); - }); - - it("parses --all-skills", () => { - const parsed = parseArgv(["--access-token=tok", "--all-skills"]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.allSkills).toBe(true); - }); - - it("parses --insecure-http", () => { - const parsed = parseArgv([ - "--access-token=tok", - "--host=sentry.internal:9000", - "--insecure-http", - ]); - expect(parsed.host).toBe("sentry.internal:9000"); - expect(parsed.insecureHttp).toBe(true); - }); - - it("parses --agent-provider=azure-openai", () => { - const parsed = parseArgv([ - "--access-token=tok", - "--agent-provider=azure-openai", - ]); - expect(parsed.agentProvider).toBe("azure-openai"); - }); - - it("parses --agent-provider=openrouter", () => { - const parsed = parseArgv([ - "--access-token=tok", - "--agent-provider=openrouter", - ]); - expect(parsed.agentProvider).toBe("openrouter"); - }); - - it("collects unknown args", () => { - const parsed = parseArgv(["--unknown", "--another=1"]); - expect(parsed.unknownArgs.length).toBeGreaterThan(0); - }); -}); - -describe("cli/parseEnv", () => { - it("parses environment variables including skills", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - SENTRY_HOST: "envhost", - MCP_URL: "envmcp", - SENTRY_DSN: "envdsn", - MCP_SKILLS: "inspect,triage", - } as any); - expect(env.accessToken).toBe("envtok"); - expect(env.host).toBe("envhost"); - expect(env.mcpUrl).toBe("envmcp"); - expect(env.sentryDsn).toBe("envdsn"); - expect(env.skills).toBe("inspect,triage"); - }); - - it("reads MCP_DISABLE_SKILLS", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_DISABLE_SKILLS: "seer", - } as any); - expect(env.disableSkills).toBe("seer"); - }); -}); - -describe("cli/merge", () => { - it("applies precedence: CLI over env", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - SENTRY_HOST: "envhost", - MCP_URL: "envmcp", - SENTRY_DSN: "envdsn", - } as any); - const cli = parseArgv([ - "--access-token=clitok", - "--host=clihost", - "--insecure-http", - "--mcp-url=climcp", - "--sentry-dsn=clidsn", - "--openai-base-url=https://api.cli/v1", - ]); - const merged = merge(cli, env); - expect(merged.accessToken).toBe("clitok"); - expect(merged.host).toBe("clihost"); - expect(merged.insecureHttp).toBe(true); - expect(merged.mcpUrl).toBe("climcp"); - expect(merged.sentryDsn).toBe("clidsn"); - expect(merged.openaiBaseUrl).toBe("https://api.cli/v1"); - }); - - it("applies precedence for skills: CLI over env", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect", - } as any); - const cli = parseArgv(["--access-token=clitok", "--skills=inspect,triage"]); - const merged = merge(cli, env); - expect(merged.skills).toBe("inspect,triage"); - }); - - it("falls back to env when CLI skills not provided", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect,triage", - } as any); - const cli = parseArgv(["--access-token=clitok"]); - const merged = merge(cli, env); - expect(merged.skills).toBe("inspect,triage"); - }); - - it("lets CLI allSkills override env skills", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect", - } as any); - const cli = parseArgv(["--access-token=clitok", "--all-skills"]); - const merged = merge(cli, env); - expect(merged.allSkills).toBe(true); - expect(merged.skills).toBeUndefined(); - }); - - it("applies precedence for disableSkills: CLI over env", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_DISABLE_SKILLS: "docs", - } as any); - const cli = parseArgv(["--access-token=clitok", "--disable-skills=seer"]); - const merged = merge(cli, env); - expect(merged.disableSkills).toBe("seer"); - }); - - it("falls back to env when CLI disableSkills not provided", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_DISABLE_SKILLS: "seer", - } as any); - const cli = parseArgv(["--access-token=clitok"]); - const merged = merge(cli, env); - expect(merged.disableSkills).toBe("seer"); - }); - - it("applies precedence for agentProvider: CLI over env", () => { - const env = parseEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - EMBEDDED_AGENT_PROVIDER: "openai", - } as any); - const cli = parseArgv([ - "--access-token=clitok", - "--agent-provider=azure-openai", - ]); - const merged = merge(cli, env); - expect(merged.agentProvider).toBe("azure-openai"); - }); -}); diff --git a/packages/mcp-server/src/cli/parse.ts b/packages/mcp-server/src/cli/parse.ts deleted file mode 100644 index 85b379e80..000000000 --- a/packages/mcp-server/src/cli/parse.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { parseArgs } from "node:util"; -import type { CliArgs, EnvArgs, MergedArgs } from "./types"; - -export function parseArgv(argv: string[]): CliArgs { - const options = { - "access-token": { type: "string" as const }, - host: { type: "string" as const }, - "insecure-http": { type: "boolean" as const }, - url: { type: "string" as const }, - "mcp-url": { type: "string" as const }, - "sentry-dsn": { type: "string" as const }, - "openai-base-url": { type: "string" as const }, - "openai-model": { type: "string" as const }, - "anthropic-base-url": { type: "string" as const }, - "anthropic-model": { type: "string" as const }, - "agent-provider": { type: "string" as const }, - "organization-slug": { type: "string" as const }, - "project-slug": { type: "string" as const }, - skills: { type: "string" as const }, - "all-skills": { type: "boolean" as const }, - "disable-skills": { type: "string" as const }, - experimental: { type: "boolean" as const }, - help: { type: "boolean" as const, short: "h" as const }, - version: { type: "boolean" as const, short: "v" as const }, - }; - - const { values, positionals, tokens } = parseArgs({ - args: argv, - options, - allowPositionals: false, - strict: false, - tokens: true, - }); - - const knownLong = new Set(Object.keys(options)); - const knownShort = new Set( - Object.values(options) - .map((o) => ("short" in o ? (o.short as string | undefined) : undefined)) - .filter(Boolean) as string[], - ); - - const unknownArgs: string[] = []; - for (const t of (tokens as any[]) || []) { - if (t.kind === "option") { - const name = t.name as string | undefined; - if (name && !(knownLong.has(name) || knownShort.has(name))) { - unknownArgs.push((t.raw as string) ?? `--${name}`); - } - } else if (t.kind === "positional") { - unknownArgs.push((t.raw as string) ?? String(t.value ?? "")); - } - } - - return { - accessToken: values["access-token"] as string | undefined, - host: values.host as string | undefined, - insecureHttp: values["insecure-http"] === true, - url: values.url as string | undefined, - mcpUrl: values["mcp-url"] as string | undefined, - sentryDsn: values["sentry-dsn"] as string | undefined, - openaiBaseUrl: values["openai-base-url"] as string | undefined, - openaiModel: values["openai-model"] as string | undefined, - anthropicBaseUrl: values["anthropic-base-url"] as string | undefined, - anthropicModel: values["anthropic-model"] as string | undefined, - agentProvider: values["agent-provider"] as string | undefined, - organizationSlug: values["organization-slug"] as string | undefined, - projectSlug: values["project-slug"] as string | undefined, - skills: values.skills as string | undefined, - allSkills: values["all-skills"] === true, - disableSkills: values["disable-skills"] as string | undefined, - experimental: values.experimental === true, - help: (values.help as boolean | undefined) === true, - version: (values.version as boolean | undefined) === true, - unknownArgs: - unknownArgs.length > 0 ? unknownArgs : (positionals as string[]) || [], - }; -} - -export function parseEnv(env: NodeJS.ProcessEnv): EnvArgs { - const fromEnv: EnvArgs = {}; - if (env.SENTRY_ACCESS_TOKEN) fromEnv.accessToken = env.SENTRY_ACCESS_TOKEN; - if (env.SENTRY_URL) fromEnv.url = env.SENTRY_URL; - if (env.SENTRY_HOST) fromEnv.host = env.SENTRY_HOST; - if (env.MCP_URL) fromEnv.mcpUrl = env.MCP_URL; - if (env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN) - fromEnv.sentryDsn = env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN; - - if (env.OPENAI_MODEL) fromEnv.openaiModel = env.OPENAI_MODEL; - if (env.ANTHROPIC_MODEL) fromEnv.anthropicModel = env.ANTHROPIC_MODEL; - if (env.EMBEDDED_AGENT_PROVIDER) - fromEnv.agentProvider = env.EMBEDDED_AGENT_PROVIDER; - if (env.SENTRY_CLIENT_ID) fromEnv.clientId = env.SENTRY_CLIENT_ID; - if (env.MCP_SKILLS) fromEnv.skills = env.MCP_SKILLS; - if (env.MCP_DISABLE_SKILLS) fromEnv.disableSkills = env.MCP_DISABLE_SKILLS; - return fromEnv; -} - -export function merge(cli: CliArgs, env: EnvArgs): MergedArgs { - // CLI wins over env - return { - accessToken: cli.accessToken ?? env.accessToken, - // If CLI provided url/host, prefer those; else fall back to env - url: cli.url ?? env.url, - host: cli.host ?? env.host, - insecureHttp: cli.insecureHttp === true, - mcpUrl: cli.mcpUrl ?? env.mcpUrl, - sentryDsn: cli.sentryDsn ?? env.sentryDsn, - openaiBaseUrl: cli.openaiBaseUrl, - openaiModel: cli.openaiModel ?? env.openaiModel, - anthropicBaseUrl: cli.anthropicBaseUrl, - anthropicModel: cli.anthropicModel ?? env.anthropicModel, - agentProvider: cli.agentProvider ?? env.agentProvider, - clientId: env.clientId, - // Skills precedence: CLI skills override env; --all-skills also overrides env skills. - skills: cli.skills ?? (cli.allSkills ? undefined : env.skills), - allSkills: cli.allSkills === true, - disableSkills: cli.disableSkills ?? env.disableSkills, - experimental: cli.experimental === true, - organizationSlug: cli.organizationSlug, - projectSlug: cli.projectSlug, - help: cli.help === true, - version: cli.version === true, - unknownArgs: cli.unknownArgs, - }; -} diff --git a/packages/mcp-server/src/cli/resolve.ts b/packages/mcp-server/src/cli/resolve.ts deleted file mode 100644 index d51b0baaa..000000000 --- a/packages/mcp-server/src/cli/resolve.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - ACTIVE_SKILLS, - ALL_SKILLS, - parseSkills, - type Skill, -} from "@sentry/mcp-core/skills"; -import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; -import { - validateAndParseSentryUrlThrows, - validateOpenAiBaseUrlThrows, - validateSentryHostThrows, -} from "@sentry/mcp-core/utils/url-utils"; -import { DEFAULT_SENTRY_CLIENT_ID } from "../auth/constants"; -import type { MergedArgs, PartiallyResolvedConfig } from "./types"; - -export function formatInvalidSkills( - invalid: string[], - source?: string, -): string { - const prefix = source ? `${source} provided` : "Invalid skills provided"; - return `Error: ${prefix}: ${invalid.join(", ")}\nAvailable skills: ${ALL_SKILLS.join(", ")}`; -} - -/** - * Resolves the Sentry host from url/host inputs with validation. - * Used by both the server flow (finalize) and auth commands (resolveAuthContext) - * to ensure cache keys always match. - */ -export function resolveHost(url?: string, host?: string): string { - if (url) { - return validateAndParseSentryUrlThrows(url); - } - if (host) { - validateSentryHostThrows(host); - return host; - } - return "sentry.io"; -} - -export function finalize(input: MergedArgs): PartiallyResolvedConfig { - if (input.insecureHttp && input.url) { - throw new Error( - "Error: --insecure-http cannot be used with --url or SENTRY_URL. Use --host for insecure self-hosted instances.", - ); - } - - const sentryHost = resolveHost(input.url, input.host); - - if (input.insecureHttp && isSentryHost(sentryHost)) { - throw new Error( - "Error: --insecure-http is only supported for self-hosted Sentry hosts.", - ); - } - - const sentryProtocol = input.insecureHttp ? "http" : "https"; - - // Skills resolution - // - // IMPORTANT: stdio (CLI) intentionally defaults to all active skills when no --skills flag is provided - // - // This differs from the OAuth flow, which requires explicit user selection: - // - stdio/CLI: Non-interactive, defaults to all non-deprecated skills - // - OAuth: Interactive, requires user to explicitly select skills (with sensible defaults pre-checked) - // - // Rationale: - // We don't want the MCP to break if users don't specify skills. stdio is typically used in - // local development and CI/CD environments where maximum access by default is expected. - // OAuth is used in multi-tenant hosted environments where users should consciously grant - // permissions on a per-app basis. - // - // For OAuth validation that enforces minimum 1 skill selection, see: - // packages/mcp-cloudflare/src/server/oauth/routes/callback.ts (lines 234-248) - // - let finalSkills: Set; - if (input.skills && input.allSkills) { - throw new Error("Error: --all-skills cannot be combined with --skills."); - } - - if (input.allSkills) { - finalSkills = new Set(ACTIVE_SKILLS); - } else if (input.skills) { - // Override: use only the specified skills - const { valid, invalid } = parseSkills(input.skills); - if (invalid.length > 0) { - throw new Error(formatInvalidSkills(invalid)); - } - if (valid.size === 0) { - throw new Error("Error: Invalid skills provided. No valid skills found."); - } - finalSkills = valid; - } else { - // Default: grant all active skills when no flag is provided (see comment block above for rationale) - finalSkills = new Set(ACTIVE_SKILLS); - - // Seer is a sentry.io service. Self-hosted Sentry ships without it, and its - // autofix endpoints answer 500 when the Seer backend is not configured, so - // every `get_issue_details` call (which fetches autofix state) and every - // `analyze_issue_with_seer` poll loop would just generate server errors. - // Leave Seer out of the implicit default on self-hosted hosts; users who - // do run Seer can still opt in with --skills=seer or --all-skills. - if (!isSentryHost(sentryHost)) { - finalSkills.delete("seer"); - } - } - - // Disable-skills: remove specific skills from the active set - if (input.disableSkills) { - const { valid: skillsToDisable, invalid } = parseSkills( - input.disableSkills, - ); - if (invalid.length > 0) { - throw new Error(formatInvalidSkills(invalid, "--disable-skills")); - } - for (const skill of skillsToDisable) { - finalSkills.delete(skill); - } - if (finalSkills.size === 0) { - throw new Error( - "Error: All skills have been disabled. At least one skill must remain enabled.", - ); - } - } - - const resolvedOpenAiBaseUrl = input.openaiBaseUrl - ? validateOpenAiBaseUrlThrows(input.openaiBaseUrl) - : undefined; - - // Validate anthropic base URL if provided (same validation as OpenAI) - const resolvedAnthropicBaseUrl = input.anthropicBaseUrl - ? validateOpenAiBaseUrlThrows(input.anthropicBaseUrl) - : undefined; - - // Validate agent provider if explicitly set - let agentProvider: - | "openai" - | "azure-openai" - | "anthropic" - | "openrouter" - | undefined = undefined; - if (input.agentProvider) { - const provider = input.agentProvider.toLowerCase(); - if ( - provider !== "openai" && - provider !== "azure-openai" && - provider !== "anthropic" && - provider !== "openrouter" - ) { - throw new Error( - `Error: Invalid agent provider "${input.agentProvider}". Must be "openai", "azure-openai", "anthropic", or "openrouter".`, - ); - } - agentProvider = provider; - } - - return { - accessToken: input.accessToken, - clientId: input.clientId || DEFAULT_SENTRY_CLIENT_ID, - sentryHost, - sentryProtocol, - mcpUrl: input.mcpUrl, - sentryDsn: input.sentryDsn, - openaiBaseUrl: resolvedOpenAiBaseUrl, - openaiModel: input.openaiModel, - anthropicBaseUrl: resolvedAnthropicBaseUrl, - anthropicModel: input.anthropicModel, - agentProvider, - finalSkills, - organizationSlug: input.organizationSlug, - projectSlug: input.projectSlug, - }; -} diff --git a/packages/mcp-server/src/cli/types.ts b/packages/mcp-server/src/cli/types.ts deleted file mode 100644 index 215d24941..000000000 --- a/packages/mcp-server/src/cli/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { SentryProtocol } from "@sentry/mcp-core/types"; -import type { Skill } from "@sentry/mcp-core/skills"; - -export type CliArgs = { - accessToken?: string; - host?: string; - insecureHttp?: boolean; - url?: string; - mcpUrl?: string; - sentryDsn?: string; - openaiBaseUrl?: string; - openaiModel?: string; - anthropicBaseUrl?: string; - anthropicModel?: string; - agentProvider?: string; - skills?: string; - allSkills?: boolean; - disableSkills?: string; - experimental?: boolean; - organizationSlug?: string; - projectSlug?: string; - help?: boolean; - version?: boolean; - unknownArgs: string[]; -}; - -export type EnvArgs = { - accessToken?: string; - host?: string; // parsed from SENTRY_HOST or SENTRY_URL (raw value) - url?: string; // raw URL if provided (SENTRY_URL) - mcpUrl?: string; - sentryDsn?: string; - openaiModel?: string; - anthropicModel?: string; - agentProvider?: string; - clientId?: string; - skills?: string; - disableSkills?: string; -}; - -export type MergedArgs = { - accessToken?: string; - host?: string; - insecureHttp?: boolean; - url?: string; - mcpUrl?: string; - sentryDsn?: string; - openaiBaseUrl?: string; - openaiModel?: string; - anthropicBaseUrl?: string; - anthropicModel?: string; - agentProvider?: string; - clientId?: string; // env-only, carried from EnvArgs - skills?: string; - allSkills?: boolean; - disableSkills?: string; - experimental?: boolean; - organizationSlug?: string; - projectSlug?: string; - help?: boolean; - version?: boolean; - unknownArgs: string[]; -}; - -export type CliAgentProvider = - | "openai" - | "azure-openai" - | "anthropic" - | "openrouter"; - -/** - * Partially resolved config — accessToken may not yet be available - * (will be resolved via device code flow or cache). - */ -export type PartiallyResolvedConfig = { - accessToken?: string; - clientId: string; - sentryHost: string; - sentryProtocol: SentryProtocol; - mcpUrl?: string; - sentryDsn?: string; - openaiBaseUrl?: string; - openaiModel?: string; - anthropicBaseUrl?: string; - anthropicModel?: string; - agentProvider?: CliAgentProvider; - /** Skills granted for this session (always populated by finalize()) */ - finalSkills: Set; - organizationSlug?: string; - projectSlug?: string; -}; - -export type ResolvedConfig = Omit & { - accessToken: string; -}; diff --git a/packages/mcp-server/src/cli/usage.test.ts b/packages/mcp-server/src/cli/usage.test.ts deleted file mode 100644 index 77a50e784..000000000 --- a/packages/mcp-server/src/cli/usage.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { runMcpServer } from "../index"; -import { buildUsage } from "./usage"; - -describe("buildUsage", () => { - it("describes host authentication without standalone login commands", () => { - const usage = buildUsage("sentry mcp", ["inspect"], { - usesHostAuthentication: true, - }); - - expect(usage).toContain("Uses the active Sentry CLI session"); - expect(usage).toContain("sentry auth login"); - expect(usage).not.toContain("device code flow"); - expect(usage).not.toContain("--access-token"); - expect(usage).not.toContain("auth [login|logout|status]"); - }); -}); - -describe("runMcpServer", () => { - it("returns host application setup errors without exiting", async () => { - await expect( - runMcpServer([], { - throwOnError: true, - resolveAccessToken: async () => { - throw new Error("No active CLI session"); - }, - }), - ).rejects.toThrow("No active CLI session"); - }); - - it("does not allow standalone auth commands with host authentication", async () => { - await expect( - runMcpServer(["auth", "login"], { - throwOnError: true, - resolveAccessToken: async () => "token", - }), - ).rejects.toThrow("Use `sentry auth`"); - }); -}); diff --git a/packages/mcp-server/src/cli/usage.ts b/packages/mcp-server/src/cli/usage.ts deleted file mode 100644 index c49fa4e6d..000000000 --- a/packages/mcp-server/src/cli/usage.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Skill } from "@sentry/mcp-core/skills"; - -type UsageOptions = { - usesHostAuthentication?: boolean; -}; - -export function buildUsage( - packageName: string, - allSkills: ReadonlyArray, - options: UsageOptions = {}, -): string { - if (options.usesHostAuthentication) { - return `Usage: ${packageName} [--host=] [--insecure-http] - -Authentication: - Uses the active Sentry CLI session. Authenticate with \`sentry auth login\`. - -Common optional flags: - --host Change Sentry host (self-hosted) - --insecure-http Use http:// for self-hosted --host values - --sentry-dsn Override DSN used for telemetry reporting - --experimental Enable forward-looking tool variants and experimental features - -Embedded agent configuration: - --agent-provider LLM provider: openai, azure-openai, anthropic, or openrouter (auto-detects from API keys) - --openai-base-url Override OpenAI API base URL - --openai-model Override OpenAI model (default: gpt-5) - --anthropic-base-url Override Anthropic API base URL - --anthropic-model Override Anthropic model (default: claude-sonnet-4-5) - -Session constraints: - --organization-slug Force all calls to an organization - --project-slug Optional project constraint - -Skill controls: - --skills Specify which skills to grant (default: active, non-deprecated skills; - seer is excluded by default on self-hosted hosts) - --all-skills Grant all active, non-deprecated skills (including seer on self-hosted) - --disable-skills Remove specific skills (e.g. --disable-skills=seer) - -Available skills: ${allSkills.join(", ")} - -Environment variables: - OPENAI_API_KEY OpenAI API key for AI-powered search tools - ANTHROPIC_API_KEY Anthropic API key for AI-powered search tools - OPENROUTER_API_KEY OpenRouter API key for AI-powered search tools - OPENROUTER_MODEL OpenRouter model (default: openai/gpt-5.6-luna) - OPENROUTER_REASONING_EFFORT OpenRouter reasoning effort: none|minimal|low|medium|high|xhigh (max→xhigh), or "" to omit (default: high) - EMBEDDED_AGENT_PROVIDER Provider override: openai, azure-openai, anthropic, or openrouter - MCP_SKILLS Specify which skills to grant (comma-separated) - MCP_DISABLE_SKILLS Disable specific skills (comma-separated) - -Examples: - ${packageName} - ${packageName} --all-skills - ${packageName} --skills=inspect,triage - ${packageName} --host=sentry.example.com - ${packageName} --host=sentry.internal:9000 --insecure-http - ${packageName} --agent-provider=anthropic`; - } - - return `Usage: ${packageName} [--access-token=] [--host=] [--insecure-http] - ${packageName} auth [login|logout|status] - -Commands: - auth login Authenticate via device code flow (sentry.io only) - auth logout Clear cached authentication - auth status Show current authentication state - -Authentication: - --access-token Sentry User Auth Token with API access - Optional for sentry.io (device code flow is used if omitted) - Required for self-hosted instances - -Common optional flags: - --host Change Sentry host (self-hosted) - --insecure-http Use http:// for self-hosted --host values - --sentry-dsn Override DSN used for telemetry reporting - --experimental Enable forward-looking tool variants and experimental features - -Embedded agent configuration: - --agent-provider LLM provider: openai, azure-openai, anthropic, or openrouter (auto-detects from API keys) - --openai-base-url Override OpenAI API base URL - --openai-model Override OpenAI model (default: gpt-5) - --anthropic-base-url Override Anthropic API base URL - --anthropic-model Override Anthropic model (default: claude-sonnet-4-5) - -Session constraints: - --organization-slug Force all calls to an organization - --project-slug Optional project constraint - -Skill controls: - --skills Specify which skills to grant (default: active, non-deprecated skills; - seer is excluded by default on self-hosted hosts) - --all-skills Grant all active, non-deprecated skills (including seer on self-hosted) - --disable-skills Remove specific skills (e.g. --disable-skills=seer) - -Available skills: ${allSkills.join(", ")} - -Environment variables: - SENTRY_ACCESS_TOKEN Sentry auth token (alternative to --access-token) - SENTRY_CLIENT_ID Override OAuth client ID for device code flow - OPENAI_API_KEY OpenAI API key for AI-powered search tools - ANTHROPIC_API_KEY Anthropic API key for AI-powered search tools - OPENROUTER_API_KEY OpenRouter API key for AI-powered search tools - OPENROUTER_MODEL OpenRouter model (default: openai/gpt-5.6-luna) - OPENROUTER_REASONING_EFFORT OpenRouter reasoning effort: none|minimal|low|medium|high|xhigh (max→xhigh), or "" to omit (default: high) - EMBEDDED_AGENT_PROVIDER Provider override: openai, azure-openai, anthropic, or openrouter - MCP_SKILLS Specify which skills to grant (comma-separated) - MCP_DISABLE_SKILLS Disable specific skills (comma-separated) - -Examples: - ${packageName} # device code auth (sentry.io only) - ${packageName} --access-token=TOKEN - ${packageName} --access-token=TOKEN --all-skills - ${packageName} --access-token=TOKEN --skills=inspect,triage - ${packageName} --access-token=TOKEN --host=sentry.example.com - ${packageName} --access-token=TOKEN --host=sentry.internal:9000 --insecure-http - ${packageName} --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer # self-hosted with Seer - ${packageName} --access-token=TOKEN --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ - ${packageName} --access-token=TOKEN --agent-provider=anthropic - ${packageName} --access-token=TOKEN --agent-provider=openrouter`; -} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts deleted file mode 100644 index cd8a1d608..000000000 --- a/packages/mcp-server/src/index.ts +++ /dev/null @@ -1,461 +0,0 @@ -#!/usr/bin/env node - -/** - * Main CLI entry point for the Sentry MCP server. - * - * Handles command-line argument parsing, environment configuration, Sentry - * initialization, and starts the MCP server with stdio transport. Supports - * device code authentication for sentry.io when no access token is provided. - * - * Subcommands: - * auth [login] — Force device code authentication - * auth logout — Clear cached authentication - * auth status — Show current authentication state - * - * @example CLI Usage - * ```bash - * npx @sentry/mcp-server --access-token=TOKEN --host=sentry.io - * npx @sentry/mcp-server auth login - * npx @sentry/mcp-server auth logout - * ``` - */ - -import { pathToFileURL } from "node:url"; - -import { - getAgentProvider, - getResolvedProviderType, - setAgentProvider, - setProviderBaseUrls, -} from "@sentry/mcp-core/internal/agents/provider-factory"; -import { buildServer } from "@sentry/mcp-core/server"; -import { SKILLS } from "@sentry/mcp-core/skills"; -import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; -import { LIB_VERSION } from "@sentry/mcp-core/version"; -import * as Sentry from "@sentry/node"; -import { resolveAccessToken } from "./auth/resolve-token"; -import { authCommand } from "./cli/commands/auth"; -import { printCliLine } from "./cli/output"; -import { merge, parseArgv, parseEnv } from "./cli/parse"; -import { finalize } from "./cli/resolve"; -import type { PartiallyResolvedConfig } from "./cli/types"; -import { buildUsage } from "./cli/usage"; -import { startStdio } from "./transports/stdio"; - -const defaultPackageName = "@sentry/mcp-server"; -const allSkills = Object.keys(SKILLS) as ReadonlyArray< - (typeof SKILLS)[keyof typeof SKILLS]["id"] ->; - -export type McpServerOptions = { - /** - * Supplies credentials from a host application instead of the standalone - * device-code and cache flow. - */ - resolveAccessToken?: (config: PartiallyResolvedConfig) => Promise; - /** - * Invoked when a tool call surfaces an upstream 401. The host application can - * refresh its credential and call `setAccessToken` so later tool calls use - * the new token without restarting the server. - */ - onUpstreamUnauthorized?: ( - setAccessToken: (token: string) => void, - ) => void | Promise; - /** Command name used in usage output. */ - packageName?: string; - /** Environment used for server configuration. */ - environment?: NodeJS.ProcessEnv; - /** Throw setup errors instead of printing usage and exiting the process. */ - throwOnError?: boolean; -}; - -/** Start the stdio MCP server with either standalone or host-provided auth. */ -export async function runMcpServer( - rawArgs = process.argv.slice(2), - options: McpServerOptions = {}, -) { - const packageName = options.packageName ?? defaultPackageName; - const usageText = buildUsage(packageName, allSkills, { - usesHostAuthentication: options.resolveAccessToken !== undefined, - }); - - function die(error: unknown): never { - if (options.throwOnError) { - throw error; - } - console.error(error instanceof Error ? error.message : String(error)); - console.error(usageText); - process.exit(1); - } - - // Handle subcommands before normal server parsing - if (rawArgs[0] === "auth") { - if (options.resolveAccessToken) { - die( - new Error("Use `sentry auth` to manage credentials for `sentry mcp`."), - ); - } - await authCommand(rawArgs.slice(1)); - return; - } - - const cli = parseArgv(rawArgs); - if (cli.help) { - printCliLine(usageText); - process.exit(0); - } - if (cli.version) { - printCliLine(`${packageName} ${LIB_VERSION}`); - process.exit(0); - } - if (cli.unknownArgs.length > 0) { - die(new Error(`Error: Invalid argument(s): ${cli.unknownArgs.join(", ")}`)); - } - - const env = parseEnv(options.environment ?? process.env); - const partialCfg = (() => { - try { - return finalize(merge(cli, env)); - } catch (err) { - die(err); - } - })(); - - // Resolve access token before starting the transport. - // For sentry.io without a token, this blocks on device code flow — - // the client won't connect until the user has authenticated. - const cfg = await ( - options.resolveAccessToken - ? options.resolveAccessToken(partialCfg).then((accessToken) => ({ - ...partialCfg, - accessToken, - })) - : resolveAccessToken(partialCfg) - ).catch((err) => { - die(err); - }); - - // Configure embedded agent provider - if (cfg.agentProvider) { - setAgentProvider(cfg.agentProvider); - } - setProviderBaseUrls({ - openaiBaseUrl: cfg.openaiBaseUrl, - anthropicBaseUrl: cfg.anthropicBaseUrl, - }); - if (cfg.openaiModel) { - process.env.OPENAI_MODEL = cfg.openaiModel; - } - if (cfg.anthropicModel) { - process.env.ANTHROPIC_MODEL = cfg.anthropicModel; - } - - // Helper functions for provider status messages - function hasProviderConflict(): boolean { - const providerKeyCount = [ - process.env.ANTHROPIC_API_KEY, - process.env.OPENAI_API_KEY, - process.env.OPENROUTER_API_KEY, - ].filter(Boolean).length; - const hasExplicitProvider = - cfg.agentProvider || process.env.EMBEDDED_AGENT_PROVIDER; - return providerKeyCount > 1 && !hasExplicitProvider; - } - - function getConfiguredProvider(): string | undefined { - return ( - cfg.agentProvider || process.env.EMBEDDED_AGENT_PROVIDER?.toLowerCase() - ); - } - - function hasProviderMismatch(): { - mismatch: boolean; - configured?: string; - availableKey?: string; - } { - const configured = getConfiguredProvider(); - if (!configured) return { mismatch: false }; - - const hasAnthropic = Boolean(process.env.ANTHROPIC_API_KEY); - const hasOpenAI = Boolean(process.env.OPENAI_API_KEY); - const hasOpenRouter = Boolean(process.env.OPENROUTER_API_KEY); - - // Check if configured provider's key is missing but other key is present - if ( - (configured === "openai" || configured === "azure-openai") && - !hasOpenAI && - (hasAnthropic || hasOpenRouter) - ) { - return { - mismatch: true, - configured, - availableKey: hasOpenRouter - ? "OPENROUTER_API_KEY" - : "ANTHROPIC_API_KEY", - }; - } - if ( - configured === "anthropic" && - !hasAnthropic && - (hasOpenAI || hasOpenRouter) - ) { - return { - mismatch: true, - configured: "anthropic", - availableKey: hasOpenRouter ? "OPENROUTER_API_KEY" : "OPENAI_API_KEY", - }; - } - if ( - configured === "openrouter" && - !hasOpenRouter && - (hasOpenAI || hasAnthropic) - ) { - return { - mismatch: true, - configured: "openrouter", - availableKey: hasOpenAI ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY", - }; - } - - return { mismatch: false }; - } - - function getProviderSource(): string { - // Check CLI flag first (cli.agentProvider is only set by --agent-provider flag) - if (cli.agentProvider) return "explicitly configured"; - // Then check env var (process.env takes precedence over cfg since cfg merges both) - if (process.env.EMBEDDED_AGENT_PROVIDER) - return "from EMBEDDED_AGENT_PROVIDER"; - return "auto-detected"; - } - - // Check for LLM API keys and warn if none available - const resolvedProvider = getResolvedProviderType() as - | "openai" - | "azure-openai" - | "anthropic" - | "openrouter" - | undefined; - - if (!resolvedProvider) { - const mismatchInfo = hasProviderMismatch(); - let providerConfigError: string | undefined; - try { - getAgentProvider(); - } catch (error) { - providerConfigError = - error instanceof Error ? error.message : String(error); - } - - if (hasProviderConflict()) { - console.warn( - "Warning: Multiple LLM API keys are set, but no provider is explicitly configured.", - ); - console.warn( - "Please set EMBEDDED_AGENT_PROVIDER='openai', 'azure-openai', 'anthropic', or 'openrouter' to specify which provider to use.", - ); - console.warn( - "AI-powered search tools will be unavailable until a provider is selected.", - ); - } else if (mismatchInfo.mismatch) { - const expectedKey = - mismatchInfo.configured === "openai" || - mismatchInfo.configured === "azure-openai" - ? "OPENAI_API_KEY" - : mismatchInfo.configured === "openrouter" - ? "OPENROUTER_API_KEY" - : "ANTHROPIC_API_KEY"; - const configuredViaCliFlag = Boolean(cli.agentProvider); - const providerSetting = configuredViaCliFlag - ? `--agent-provider=${mismatchInfo.configured}` - : `EMBEDDED_AGENT_PROVIDER='${mismatchInfo.configured}'`; - const changeProviderHint = configuredViaCliFlag - ? "Change --agent-provider to match your available API key" - : "Change EMBEDDED_AGENT_PROVIDER to match your available API key"; - console.warn( - `Warning: ${providerSetting} but ${expectedKey} is not set.`, - ); - console.warn(`Found ${mismatchInfo.availableKey} instead. Either:`); - console.warn( - ` - Set ${expectedKey} to use the ${mismatchInfo.configured} provider, or`, - ); - console.warn(` - ${changeProviderHint}`); - console.warn( - "AI-powered search tools will be unavailable until this is resolved.", - ); - } else if ( - providerConfigError && - !providerConfigError.startsWith("No embedded agent provider configured") - ) { - console.warn(`Warning: ${providerConfigError}`); - console.warn( - "AI-powered search tools will be unavailable until this is resolved.", - ); - } else { - console.warn( - "Warning: No LLM API key found (OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY).", - ); - console.warn("AI-powered search tools will be unavailable."); - console.warn( - "Search tools still work with direct Sentry query syntax via the 'query' parameter.", - ); - } - console.warn(""); - } else { - const providerSource = getProviderSource(); - const providerLabel = getAgentProvider().label; - console.warn( - `Using ${providerLabel} for AI-powered search tools (${providerSource}).`, - ); - // Warn about auto-detection deprecation - if (providerSource === "auto-detected") { - console.warn( - "Deprecation warning: Auto-detection of LLM provider is deprecated.", - ); - console.warn( - `Please set EMBEDDED_AGENT_PROVIDER='${resolvedProvider}' explicitly.`, - ); - console.warn("Auto-detection will be removed in a future release."); - } - console.warn(""); - } - - const scopeContext = { - "app.server.version": LIB_VERSION, - "app.transport": "stdio", - "app.upstream.host": cfg.sentryHost, - "app.url.full": cfg.mcpUrl, - }; - - Sentry.init({ - dsn: cfg.sentryDsn, - tracesSampleRate: 1, - beforeSend: sentryBeforeSend, - initialScope: { - tags: { - ...scopeContext, - "app.server.mode.experimental": cli.experimental ? "true" : "false", - }, - // SDK v11 does not copy scope tags onto streamed spans. - attributes: { - ...scopeContext, - "app.server.mode.experimental": cli.experimental === true, - }, - }, - release: process.env.SENTRY_RELEASE, - integrations: [ - Sentry.consoleLoggingIntegration(), - Sentry.zodErrorsIntegration(), - Sentry.vercelAIIntegration({ - recordInputs: true, - recordOutputs: true, - }), - ], - environment: - process.env.SENTRY_ENVIRONMENT ?? - (process.env.NODE_ENV !== "production" ? "development" : "production"), - }); - - // Log experimental mode status - if (cli.experimental) { - console.warn( - "Experimental mode enabled: Forward-looking tool variants and experimental features are available.", - ); - console.warn(""); - } - - const SENTRY_TIMEOUT = 5000; // 5 seconds - - // Graceful shutdown handlers - async function shutdown(signal: string) { - console.error(`${signal} received, shutting down...`); - await Sentry.flush(SENTRY_TIMEOUT); - process.exit(0); - } - - process.on("SIGTERM", () => shutdown("SIGTERM")); - process.on("SIGINT", () => shutdown("SIGINT")); - - // If the client dies and closes all pipes, console.error can throw EPIPE - // inside these handlers. Without a guard, that recurses forever and burns CPU. - // See getsentry/sentry-mcp#1274. - let exiting = false; - function exitAfterError(error: unknown, label: string) { - if (exiting) { - process.exit(1); - return; - } - exiting = true; - - try { - console.error(label, error); - } catch { - // stderr may already be closed - } - - try { - Sentry.captureException(error); - } catch { - // reporting must not block exit - } - - // Don't depend on flush finishing — exit even if it hangs or rejects. - const timer = setTimeout(() => process.exit(1), SENTRY_TIMEOUT); - timer.unref(); - void Sentry.flush(SENTRY_TIMEOUT).finally(() => process.exit(1)); - } - - process.on("uncaughtException", (error) => { - exitAfterError(error, "Uncaught exception:"); - }); - - process.on("unhandledRejection", (reason) => { - exitAfterError(reason, "Unhandled rejection:"); - }); - - const context = { - accessToken: cfg.accessToken, - grantedSkills: cfg.finalSkills, - constraints: { - organizationSlug: cfg.organizationSlug ?? null, - projectSlug: cfg.projectSlug ?? null, - regionUrl: null, - }, - sentryHost: cfg.sentryHost, - sentryProtocol: cfg.sentryProtocol, - mcpUrl: cfg.mcpUrl, - openaiBaseUrl: cfg.openaiBaseUrl, - experimentalMode: cli.experimental, - transport: "stdio" as const, - // Let the host refresh its credential on an upstream 401 and write it back - // so subsequent tool calls use the new token. Tool handlers read - // `context.accessToken` fresh per call, so mutating it here is enough. - onUpstreamUnauthorized: options.onUpstreamUnauthorized - ? () => - options.onUpstreamUnauthorized?.((token) => { - context.accessToken = token; - }) - : undefined, - }; - - // Build server with context to filter tools based on granted skills - // Use experimentalMode when --experimental flag is set (enables forward-looking variants) - const server = buildServer({ - context, - experimentalMode: cli.experimental, - }); - - startStdio(server, context).catch((err) => { - exitAfterError(err, "Server error:"); - }); -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - void runMcpServer().catch((err) => { - console.error("Fatal error:", err); - process.exit(1); - }); -} diff --git a/packages/mcp-server/src/transports/stdio.ts b/packages/mcp-server/src/transports/stdio.ts deleted file mode 100644 index 643ac9148..000000000 --- a/packages/mcp-server/src/transports/stdio.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -/** - * Standard I/O Transport for MCP Server. - * - * Provides stdio-based communication for the Sentry MCP server, typically used - * when the server runs as a subprocess communicating via stdin/stdout pipes. - * - * @example Basic Usage - * ```typescript - * import { Server } from "@modelcontextprotocol/sdk/server/index.js"; - * import { startStdio } from "./transports/stdio.js"; - * - * const server = new Server(); - * const context = { - * accessToken: process.env.SENTRY_TOKEN, - * host: "sentry.io" - * }; - * - * await startStdio(server, context); - * ``` - */ -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { LIB_VERSION } from "@sentry/mcp-core/version"; -import * as Sentry from "@sentry/node"; - -export type StdioServerContext = { - sentryHost?: string; - mcpUrl?: string; - experimentalMode?: boolean; -}; - -function getStdioSpanAttributes( - context: StdioServerContext, -): Record { - const attributes: Record = { - "app.transport": "stdio", - "app.server.version": LIB_VERSION, - "app.server.mode.experimental": context.experimentalMode ?? false, - "network.transport": "pipe", - "service.version": LIB_VERSION, - }; - - if (context.sentryHost) { - attributes["app.upstream.host"] = context.sentryHost; - } - if (context.mcpUrl) { - attributes["app.url.full"] = context.mcpUrl; - } - - return attributes; -} - -/** - * Starts the MCP server with stdio transport and telemetry. - * - * Connects the server using stdio transport for process-based communication. - * Context is already captured in tool handler closures during buildServer(). - * All operations are wrapped in Sentry tracing for observability. - * - * @param server - Configured and instrumented MCP server instance (with context in closures) - * @param context - Context values used for telemetry attributes - * - * @example CLI Integration - * ```typescript - * import { buildServer } from "./server.js"; - * import { startStdio } from "./transports/stdio.js"; - * - * const context = { - * accessToken: userToken, - * sentryHost: "sentry.io", - * userId: "user-123", - * clientId: "cursor-ide", - * constraints: {} - * }; - * - * const server = buildServer({ context }); // Context captured in closures - * await startStdio(server, context); - * ``` - */ -export async function startStdio( - server: McpServer, - context: Context, -) { - await Sentry.startNewTrace(async () => { - return await Sentry.startSpan( - { - name: "mcp.server/stdio", - attributes: getStdioSpanAttributes(context), - }, - async () => { - // Context already captured in tool handler closures during buildServer() - const transport = new StdioServerTransport(); - await server.connect(transport); - }, - ); - }); -} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json deleted file mode 100644 index 07eb7e9f6..000000000 --- a/packages/mcp-server/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@sentry/mcp-server-tsconfig/tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/packages/mcp-server/tsdown.config.ts b/packages/mcp-server/tsdown.config.ts deleted file mode 100644 index e52edf498..000000000 --- a/packages/mcp-server/tsdown.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "tsdown"; - -export default defineConfig({ - entry: ["src/**/*.ts", "!src/**/*.test.ts"], - format: ["cjs", "esm"], - dts: true, - sourcemap: true, - clean: true, - external: [ - // Only mark test-only packages as external - "@sentry/mcp-server-mocks", - // Everything else (including @sentry/mcp-core) will be bundled - ], - env: { - SENTRY_ENVIRONMENT: "stdio", - npm_package_version: "{{version}}", - }, -}); diff --git a/packages/mcp-test-client/README.md b/packages/mcp-test-client/README.md index cbbd59c3f..29487541e 100644 --- a/packages/mcp-test-client/README.md +++ b/packages/mcp-test-client/README.md @@ -126,11 +126,9 @@ SENTRY_ACCESS_TOKEN=your_token pnpm mcp-test-client --transport stdio pnpm mcp-test-client --transport stdio --list-tools ``` -When no token is provided in stdio mode: - -- `sentry.io` uses cached auth if available -- `sentry.io` starts device-code auth when running interactively -- non-interactive runs fail with the same auth error as the stdio server +Stdio mode runs the workspace CLI's `sentry mcp`. When no token is provided, +it uses the `sentry auth login` session; non-interactive runs without one fail +with the same auth error as `sentry mcp`. ### Interactive Mode (Default) @@ -202,7 +200,7 @@ pnpm typecheck If you see "Failed to connect to MCP server": -1. Ensure the mcp-server package is built +1. Ensure `@sentry/mcp-core` is built (`pnpm --filter @sentry/mcp-core build`) 2. Check that your access token is valid 3. Verify the Sentry host URL is correct @@ -222,7 +220,7 @@ To verify stdio auth behavior without an LLM provider key, use: pnpm mcp-test-client --transport stdio --list-tools ``` -This is useful for checking cached-token auth, device-code auth startup, and tool discovery over stdio. +This is useful for checking CLI-session auth and tool discovery over stdio. ### Tool Errors diff --git a/packages/mcp-test-client/src/mcp-test-client.ts b/packages/mcp-test-client/src/mcp-test-client.ts index 635940a3e..de30d700e 100644 --- a/packages/mcp-test-client/src/mcp-test-client.ts +++ b/packages/mcp-test-client/src/mcp-test-client.ts @@ -26,17 +26,24 @@ export async function connectToMCPServer( }, async (span) => { try { - // Resolve the path to the mcp-server binary - const __dirname = dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = join( - __dirname, - "../../mcp-server/dist/index.js", + // Run the workspace CLI's `sentry mcp` from source. + const cliDir = join( + dirname(fileURLToPath(import.meta.url)), + "../../cli", ); const launchConfig = buildStdioServerLaunchConfig(config); const transport = new Experimental_StdioMCPTransport({ - command: "node", - args: [mcpServerPath, ...launchConfig.args], + command: "pnpm", + args: [ + "--silent", + "--dir", + cliDir, + "run", + "cli", + "mcp", + ...launchConfig.args, + ], env: launchConfig.env, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eca5ecb84..bd5c7892f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,9 +471,9 @@ importers: '@sentry/core': specifier: 'catalog:' version: 11.6.0 - '@sentry/mcp-server': + '@sentry/mcp-core': specifier: workspace:* - version: link:../mcp-server + version: link:../mcp-core '@sentry/node': specifier: 'catalog:' version: 11.6.0(@babel/core@7.28.0)(rollup@4.62.4) @@ -854,49 +854,9 @@ importers: packages/mcp-server: dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.32.0 - version: 1.32.0(@cfworker/json-schema@4.1.1)(zod@4.6.7) - '@sentry/core': - specifier: 'catalog:' - version: 11.6.0 - '@sentry/node': - specifier: 'catalog:' - version: 11.6.0(@babel/core@7.28.0)(rollup@4.62.4) - dotenv: - specifier: 'catalog:' - version: 16.6.1 - zod: - specifier: 'catalog:' - version: 4.6.7 - devDependencies: - '@sentry/mcp-core': - specifier: workspace:* - version: link:../mcp-core - '@sentry/mcp-server-mocks': - specifier: workspace:* - version: link:../mcp-server-mocks - '@sentry/mcp-server-tsconfig': - specifier: workspace:* - version: link:../mcp-server-tsconfig - '@sentry/toolkit-core': - specifier: workspace:* - version: link:../toolkit-core - '@types/node': - specifier: 'catalog:' - version: 22.16.0 - tsdown: - specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) - tsx: - specifier: 'catalog:' - version: 4.20.3 - typescript: - specifier: 'catalog:' - version: 5.8.3 - vitest: - specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.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)) + sentry: + specifier: workspace:>=0.48.0-dev.0 + version: link:../cli packages/mcp-server-evals: dependencies: @@ -912,9 +872,6 @@ importers: '@sentry/mcp-core': specifier: workspace:* version: link:../mcp-core - '@sentry/mcp-server': - specifier: workspace:* - version: link:../mcp-server '@sentry/mcp-server-mocks': specifier: workspace:* version: link:../mcp-server-mocks diff --git a/scripts/check-workspace-deps.mjs b/scripts/check-workspace-deps.mjs new file mode 100644 index 000000000..37ce59af3 --- /dev/null +++ b/scripts/check-workspace-deps.mjs @@ -0,0 +1,159 @@ +/** + * Fails when workspace package manifests break the dependency direction. + * + * The CLI hosts the MCP server, so `sentry` depends on `@sentry/mcp-core`, + * which depends on `@sentry/toolkit-core`. The reverse edges would create a + * package cycle, so they are rejected here, along with any other cycle between + * workspace packages. See docs/architecture/overview.md. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const DEPENDENCY_FIELDS = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", +]; + +/** Packages that must never reach the listed packages, even transitively. */ +export const FORBIDDEN_DEPENDENCIES = { + "@sentry/toolkit-core": ["sentry", "@sentry/mcp-core"], + "@sentry/mcp-core": ["sentry", "@sentry/mcp-cloudflare"], +}; + +/** + * Build the workspace-internal dependency graph. + * + * @param {{ name: string, [field: string]: unknown }[]} manifests + * @returns {Map} + */ +export function buildGraph(manifests) { + const names = new Set(manifests.map(({ name }) => name)); + return new Map( + manifests.map((manifest) => [ + manifest.name, + [ + ...new Set( + DEPENDENCY_FIELDS.flatMap((field) => + Object.keys(manifest[field] ?? {}), + ).filter((dependency) => names.has(dependency)), + ), + ].sort(), + ]), + ); +} + +/** + * Find dependency cycles, each reported once as a closed path. + * + * @param {Map} graph + * @returns {string[][]} + */ +export function findCycles(graph) { + const cycles = []; + const seen = new Set(); + const state = new Map(); + const stack = []; + + function visit(name) { + state.set(name, "visiting"); + stack.push(name); + for (const dependency of graph.get(name) ?? []) { + if (state.get(dependency) === "visiting") { + const cycle = [...stack.slice(stack.indexOf(dependency)), dependency]; + const key = cycle + .slice(0, -1) + .toSorted((a, b) => a.localeCompare(b)) + .join("\0"); + if (!seen.has(key)) { + seen.add(key); + cycles.push(cycle); + } + } else if (!state.has(dependency)) { + visit(dependency); + } + } + stack.pop(); + state.set(name, "done"); + } + + for (const name of [...graph.keys()].sort()) { + if (!state.has(name)) { + visit(name); + } + } + return cycles; +} + +/** + * Find forbidden edges, including ones reached through other packages. + * + * @param {Map} graph + * @param {Record} [forbidden] + * @returns {string[][]} Dependency paths from the restricted package + */ +export function findForbiddenPaths(graph, forbidden = FORBIDDEN_DEPENDENCIES) { + const violations = []; + for (const [from, targets] of Object.entries(forbidden)) { + const queue = [[from]]; + const visited = new Set([from]); + while (queue.length > 0) { + const path = queue.shift(); + for (const dependency of graph.get(path.at(-1)) ?? []) { + const next = [...path, dependency]; + if (targets.includes(dependency)) { + violations.push(next); + } + if (!visited.has(dependency)) { + visited.add(dependency); + queue.push(next); + } + } + } + } + return violations; +} + +function readWorkspaceManifests(root) { + const listed = JSON.parse( + execFileSync("pnpm", ["list", "--recursive", "--depth", "-1", "--json"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }), + ); + return listed + .filter(({ path }) => resolve(path) !== root) + .map(({ path }) => + JSON.parse(readFileSync(resolve(path, "package.json"), "utf8")), + ); +} + +function main() { + const root = resolve(import.meta.dirname, ".."); + const graph = buildGraph(readWorkspaceManifests(root)); + const errors = [ + ...findCycles(graph).map( + (cycle) => `Workspace dependency cycle: ${cycle.join(" -> ")}`, + ), + ...findForbiddenPaths(graph).map( + (path) => + `Forbidden workspace dependency: ${path.join(" -> ")} (see docs/architecture/overview.md#package-dependency-direction)`, + ), + ]; + if (errors.length > 0) { + process.stderr.write(`${errors.join("\n")}\n`); + process.exitCode = 1; + return; + } + process.stdout.write( + `Workspace dependency graph OK (${graph.size} packages, no cycles).\n`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/check-workspace-deps.test.mjs b/scripts/check-workspace-deps.test.mjs new file mode 100644 index 000000000..a6ffbae5b --- /dev/null +++ b/scripts/check-workspace-deps.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildGraph, + findCycles, + findForbiddenPaths, +} from "./check-workspace-deps.mjs"; + +const layered = [ + { name: "sentry", devDependencies: { "@sentry/mcp-core": "workspace:*" } }, + { + name: "@sentry/mcp-core", + dependencies: { "@sentry/toolkit-core": "workspace:*", zod: "^4" }, + }, + { + name: "@sentry/mcp-cloudflare", + dependencies: { "@sentry/mcp-core": "workspace:*" }, + }, + { name: "@sentry/toolkit-core" }, + { name: "@sentry/mcp-server", dependencies: { sentry: "workspace:*" } }, +]; + +describe("check-workspace-deps", () => { + it("accepts the CLI -> mcp-core -> toolkit-core layering", () => { + const graph = buildGraph(layered); + assert.deepEqual(graph.get("@sentry/mcp-core"), ["@sentry/toolkit-core"]); + assert.deepEqual(findCycles(graph), []); + assert.deepEqual(findForbiddenPaths(graph), []); + }); + + it("reports a cycle once", () => { + const graph = buildGraph([ + ...layered.filter(({ name }) => name !== "@sentry/mcp-core"), + { + name: "@sentry/mcp-core", + dependencies: { "@sentry/toolkit-core": "workspace:*" }, + devDependencies: { sentry: "workspace:*" }, + }, + ]); + assert.deepEqual(findCycles(graph), [ + ["@sentry/mcp-core", "sentry", "@sentry/mcp-core"], + ]); + }); + + it("rejects mcp-core depending on the CLI, even transitively", () => { + const graph = buildGraph([ + { name: "sentry" }, + { name: "helper", dependencies: { sentry: "workspace:*" } }, + { name: "@sentry/toolkit-core" }, + { + name: "@sentry/mcp-core", + dependencies: { "@sentry/toolkit-core": "workspace:*" }, + peerDependencies: { helper: "*" }, + }, + ]); + assert.deepEqual(findForbiddenPaths(graph), [ + ["@sentry/mcp-core", "helper", "sentry"], + ]); + }); + + it("rejects toolkit-core depending on mcp-core", () => { + const graph = buildGraph([ + ...layered.filter(({ name }) => name !== "@sentry/toolkit-core"), + { + name: "@sentry/toolkit-core", + devDependencies: { "@sentry/mcp-core": "workspace:*" }, + }, + ]); + assert.deepEqual(findForbiddenPaths(graph), [ + ["@sentry/toolkit-core", "@sentry/mcp-core"], + ]); + }); +}); diff --git a/scripts/oxc-config.test.mjs b/scripts/oxc-config.test.mjs index 6d8ed0804..8f7cd0a9a 100644 --- a/scripts/oxc-config.test.mjs +++ b/scripts/oxc-config.test.mjs @@ -70,3 +70,22 @@ 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"); + const shared = overrides.find(({ files }) => + files.includes("packages/mcp-core/**/*.{ts,tsx,js,mjs,cjs}"), + ); + assert.ok( + shared.files.includes("packages/toolkit-core/**/*.{ts,tsx,js,mjs,cjs}"), + ); + const [level, { paths, patterns }] = shared.rules["no-restricted-imports"]; + assert.equal(level, "error"); + assert.deepEqual( + paths.map(({ name }) => name), + ["sentry"], + ); + assert.ok(patterns[0].group.includes("sentry/**")); + assert.match(scripts.lint, /pnpm run lint:deps/); +}); diff --git a/server.json b/server.json index ae9c1b41a..3c73d5ab4 100644 --- a/server.json +++ b/server.json @@ -3,21 +3,22 @@ "name": "io.github.getsentry/sentry-mcp", "description": "MCP server for Sentry - error monitoring, issue tracking, and debugging for AI assistants", "repository": { - "url": "https://github.com/getsentry/sentry-mcp", + "url": "https://github.com/getsentry/toolkit", "source": "github", - "subfolder": "packages/mcp-server" + "subfolder": "packages/cli" }, - "version": "0.40.0", + "version": "0.48.0", "packages": [ { "registryType": "npm", - "identifier": "@sentry/mcp-server", - "version": "0.40.0", + "identifier": "sentry", + "version": "0.48.0", "transport": { "type": "stdio" }, + "packageArguments": [{ "type": "positional", "value": "mcp" }], "environmentVariables": [ { - "description": "Your Sentry user authentication token", - "isRequired": true, + "description": "Sentry user auth token. Optional when you have run `sentry auth login`.", + "isRequired": false, "format": "string", "isSecret": true, "name": "SENTRY_ACCESS_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", From 0a9030e164fccecb57f52fefdab57bba1aed5827 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:49:03 +0000 Subject: [PATCH 02/29] fix(cli): Keep SENTRY_ACCESS_TOKEN docs out of command example checks The doc example checker reads backticked sentry commands in the generated configuration page and does not know about `sentry mcp` passthrough flags. Co-Authored-By: BYK --- packages/cli/src/lib/env-registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 52e9c83a4..3031fb2ad 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -69,7 +69,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_ACCESS_TOKEN", description: - "Static auth token for `sentry mcp`, kept for MCP client configurations written for the retired `@sentry/mcp-server` package (same as `sentry mcp --access-token`). It replaces the CLI session for the MCP server only and is never refreshed. Other commands ignore it.", + "Static auth token for `sentry mcp`, kept for MCP client configurations written for the retired `@sentry/mcp-server` package (the env form of its `--access-token` flag). It replaces the CLI session for the MCP server only and is never refreshed. Other commands ignore it.", example: "sntryu_YOUR_TOKEN_HERE", }, // -- Targeting -- From d0d205ebe69cdbe9b4f4d891bdf1367b49b47128 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:50:58 +0000 Subject: [PATCH 03/29] fix(mcp): Tag sentry mcp telemetry with the stdio environment The retired package fixed the SDK environment to "stdio" at build time. Co-Authored-By: BYK --- packages/cli/src/lib/mcp/server.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index f62b639c6..085069b65 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -263,9 +263,8 @@ function initMcpTelemetry( zodErrorsIntegration(), vercelAIIntegration({ recordInputs: true, recordOutputs: true }), ], - environment: - env.SENTRY_ENVIRONMENT ?? - (env.NODE_ENV !== "production" ? "development" : "production"), + // The retired package fixed this at build time; keep the same value. + environment: "stdio", }); } From 38feffcd0f5e2a98602c89daf097e95e4a22d081 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:17:18 +0000 Subject: [PATCH 04/29] fix(mcp): Use refreshed tokens after an upstream 401 and fix the fx snippet Tool handlers read mcp-core's own copy of the server context, so updating the host's context after a refresh had no effect. onUpstreamUnauthorized can now return the new token, and mcp-core stores it where handlers read it. The fx profile snippet also ran plain `npx sentry@latest`. Co-Authored-By: BYK --- packages/cli/src/lib/mcp/server.ts | 11 ++--- .../components/fragments/instructions/fx.tsx | 2 +- packages/mcp-core/src/server.test.ts | 43 +++++++++++++++++++ packages/mcp-core/src/server.ts | 10 +++-- packages/mcp-core/src/types.ts | 7 +-- 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 085069b65..693aab4c5 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -330,7 +330,6 @@ export function serveMcpStdio( } const exitAfterError = installProcessHandlers(); - const { refreshAccessToken } = options; const context: ServerContext = { accessToken: options.accessToken, grantedSkills: config.finalSkills, @@ -345,13 +344,9 @@ export function serveMcpStdio( openaiBaseUrl: config.openaiBaseUrl, experimentalMode: config.experimental, transport: "stdio", - // Tool handlers read `context.accessToken` per call, so swapping it here - // lets a long-running server recover from an expired CLI session. - onUpstreamUnauthorized: refreshAccessToken - ? async () => { - context.accessToken = await refreshAccessToken(); - } - : undefined, + // 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({ diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx index 8853f3740..81e413149 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx @@ -65,7 +65,7 @@ export function FxInstructions({ transport }: FxInstructionsProps) { mcp: { sentry: { type: "stdio", - command: ["npx", `${NPM_PACKAGE_NAME}@latest`], + command: ["npx", "-y", `${NPM_PACKAGE_NAME}@latest`, "mcp"], environment: { SENTRY_ACCESS_TOKEN: "sentry-user-token", OPENAI_API_KEY: "your-openai-key", diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index d0a99a772..f4d24a1d2 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -10,6 +10,7 @@ import { import { HttpResponse, http } from "msw"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { ApiAuthenticationError } from "./api-client"; import { UserInputError } from "./errors"; import { structuredResult } from "./internal/tool-helpers/results"; import { buildServer } from "./server"; @@ -175,6 +176,48 @@ describe("buildServer", () => { ...options, }); + describe("upstream 401", () => { + it("uses the token returned by onUpstreamUnauthorized for later calls", async () => { + const seenTokens: string[] = []; + const server = buildServer({ + context: { + ...baseContext, + accessToken: "expired-token", + onUpstreamUnauthorized: async () => "fresh-token", + }, + tools: { + token_tool: createMockTool("token_tool", { + handler: async (_params, context) => { + seenTokens.push(context.accessToken); + if (context.accessToken === "expired-token") { + throw new ApiAuthenticationError("Unauthorized"); + } + return "ok"; + }, + }), + }, + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: "server-test-client", + version: "1.0.0", + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + await client.callTool({ name: "token_tool", arguments: {} }); + await client.callTool({ name: "token_tool", arguments: {} }); + } finally { + await client.close(); + await server.close(); + } + + expect(seenTokens).toEqual(["expired-token", "fresh-token"]); + }); + }); + describe("tool failure telemetry", () => { it("invokes the tool's onError hook when its handler throws", async () => { const onError = vi.fn(); diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index 3c584894a..42770317e 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -401,14 +401,18 @@ function configureServer({ } // Upstream 401 during a tool call — route via the transport so it - // can revoke the MCP grant; swallow callback errors since the - // formatted tool response still needs to land. + // can revoke the MCP grant or refresh the token; swallow callback + // errors since the formatted tool response still needs to land. if ( isApiAuthenticationErrorDeep(error) && context.onUpstreamUnauthorized ) { try { - await context.onUpstreamUnauthorized(); + const refreshedToken = await context.onUpstreamUnauthorized(); + // Handlers read this shared copy, not the caller's context. + if (refreshedToken) { + contextWithToolAvailability.accessToken = refreshedToken; + } } catch {} } diff --git a/packages/mcp-core/src/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; }; From 418e851d0d812196e254a8459e3eed56d8f3abfa Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:27:43 +0000 Subject: [PATCH 05/29] fix(lint): Apply the CLI import ban to toolkit-core sources Oxlint replaces a rule's options per override, so the toolkit-core browser override silently dropped the earlier CLI ban. Carry the ban there and test the last matching override for each package. Co-Authored-By: BYK --- .oxlintrc.json | 13 +++++++++---- scripts/oxc-config.test.mjs | 35 ++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 0c132e3c8..1a9766cdc 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -54,10 +54,7 @@ } }, { - "files": [ - "packages/mcp-core/**/*.{ts,tsx,js,mjs,cjs}", - "packages/toolkit-core/**/*.{ts,tsx,js,mjs,cjs}" - ], + "files": ["packages/mcp-core/**/*.{ts,tsx,js,mjs,cjs}"], "rules": { "no-restricted-imports": [ "error", @@ -178,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." @@ -292,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/scripts/oxc-config.test.mjs b/scripts/oxc-config.test.mjs index 8f7cd0a9a..42415bb63 100644 --- a/scripts/oxc-config.test.mjs +++ b/scripts/oxc-config.test.mjs @@ -74,18 +74,27 @@ test("CI lint checks formatting for commits that skip the pre-commit hook", () = test("mcp-core and toolkit-core cannot import the CLI", () => { const { overrides } = readRootJson(".oxlintrc.json"); const { scripts } = readRootJson("package.json"); - const shared = overrides.find(({ files }) => - files.includes("packages/mcp-core/**/*.{ts,tsx,js,mjs,cjs}"), - ); - assert.ok( - shared.files.includes("packages/toolkit-core/**/*.{ts,tsx,js,mjs,cjs}"), - ); - const [level, { paths, patterns }] = shared.rules["no-restricted-imports"]; - assert.equal(level, "error"); - assert.deepEqual( - paths.map(({ name }) => name), - ["sentry"], - ); - assert.ok(patterns[0].group.includes("sentry/**")); + // 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/); }); From 982a00750610813c44269d79115a9010b692d54d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:10:31 +0000 Subject: [PATCH 06/29] feat(mcp-server): Ship @sentry/mcp-server as a slim bundle of sentry mcp Bundle the CLI's stdio MCP server into one dependency-free file instead of forwarding to the sentry CLI. The package keeps its flags, env vars, and self-hosted support, and adds a plain-text `sentry-mcp auth login`. The bundle script keeps CLI-only code out (commands, markdown renderer, agent skills, completions, terminal images, Ink UI, debug files) and drops the SQLite WASM fallback, so the package requires Node.js 22.15+. It fails the build if a CLI-only module reaches the output or the file exceeds 4.5 MB. Co-Authored-By: BYK --- .github/workflows/merge-jobs.yml | 11 +- AGENTS.md | 4 +- README.md | 2 +- docs/architecture/overview.md | 10 +- docs/operations/stdio-auth.md | 10 +- docs/releases/stdio.md | 42 ++--- package.json | 2 +- packages/cli/AGENTS.md | 4 + packages/cli/package.json | 1 + packages/cli/script/bundle-mcp-server.ts | 155 ++++++++++++++++++ packages/cli/script/bundle.ts | 28 +--- .../cli/script/lib/require-alias-plugin.ts | 30 ++++ packages/cli/src/bin.ts | 18 +- packages/cli/src/cli.ts | 76 +-------- packages/cli/src/lib/env-registry.ts | 2 +- .../cli/src/lib/formatters/markdown-plain.ts | 10 ++ packages/cli/src/lib/mcp/args.ts | 8 +- packages/cli/src/lib/mcp/auth.ts | 34 ++++ packages/cli/src/lib/mcp/run.ts | 27 +++ packages/cli/src/lib/mcp/server.ts | 5 +- packages/cli/src/lib/mcp/usage.ts | 25 ++- packages/cli/src/lib/project-context.ts | 56 +++++++ .../cli/src/lib/suppress-sqlite-warning.ts | 18 ++ packages/cli/src/mcp-server.ts | 45 +++++ .../lib/formatters/markdown-plain.test.ts | 13 ++ packages/cli/test/lib/mcp/auth.test.ts | 76 +++++++++ .../cli/test/lib/mcp/usage-standalone.test.ts | 21 +++ .../security/sentryclirc-url-poison.test.ts | 2 +- packages/mcp-server/README.md | 36 ++-- packages/mcp-server/bin/sentry-mcp.js | 4 - packages/mcp-server/lib/shim.js | 73 --------- packages/mcp-server/lib/shim.test.js | 37 ----- packages/mcp-server/package.json | 21 +-- packages/mcp-server/test/bundle.test.mjs | 109 ++++++++++++ pnpm-lock.yaml | 6 +- 35 files changed, 717 insertions(+), 304 deletions(-) create mode 100644 packages/cli/script/bundle-mcp-server.ts create mode 100644 packages/cli/script/lib/require-alias-plugin.ts create mode 100644 packages/cli/src/lib/formatters/markdown-plain.ts create mode 100644 packages/cli/src/lib/mcp/auth.ts create mode 100644 packages/cli/src/lib/mcp/run.ts create mode 100644 packages/cli/src/lib/project-context.ts create mode 100644 packages/cli/src/lib/suppress-sqlite-warning.ts create mode 100644 packages/cli/src/mcp-server.ts create mode 100644 packages/cli/test/lib/formatters/markdown-plain.test.ts create mode 100644 packages/cli/test/lib/mcp/auth.test.ts create mode 100644 packages/cli/test/lib/mcp/usage-standalone.test.ts delete mode 100644 packages/mcp-server/bin/sentry-mcp.js delete mode 100644 packages/mcp-server/lib/shim.js delete mode 100644 packages/mcp-server/lib/shim.test.js create mode 100644 packages/mcp-server/test/bundle.test.mjs diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml index 039103a2b..980e486c8 100644 --- a/.github/workflows/merge-jobs.yml +++ b/.github/workflows/merge-jobs.yml @@ -34,8 +34,15 @@ jobs: - name: Run tests run: pnpm test:ci - # @sentry/mcp-server is a deprecation shim for `sentry mcp`; delete this - # step with the package after its final publish. + # @sentry/mcp-server bundles the CLI's `sentry mcp` server into one file. + - name: Bundle mcp-server + env: + SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} + run: pnpm --filter @sentry/mcp-server run bundle + + - name: Test mcp-server bundle + run: pnpm --filter @sentry/mcp-server run test:bundle + - name: Package mcp-server working-directory: packages/mcp-server run: pnpm pack --pack-destination dist diff --git a/AGENTS.md b/AGENTS.md index d5676b853..a99c69021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ toolkit/ │ ├── cli/ # Sentry CLI (see packages/cli/AGENTS.md) │ ├── toolkit-core/ # Shared CLI/MCP primitives │ ├── mcp-core/ # Core MCP implementation (private) -│ ├── mcp-server/ # Deprecated shim that runs `sentry mcp` (delete after final publish) +│ ├── mcp-server/ # `sentry mcp` bundled as @sentry/mcp-server │ ├── mcp-cloudflare/ # Web app + OAuth │ ├── mcp-server-evals/ # AI evaluation tests │ ├── mcp-server-mocks/ # MSW mocks @@ -98,7 +98,7 @@ toolkit/ **MCP releases:** -- docs/releases/stdio.md — `sentry mcp` release, registry, and the `@sentry/mcp-server` shim +- docs/releases/stdio.md — `sentry mcp` release, registry, and the `@sentry/mcp-server` bundle - docs/releases/cloudflare.md — Cloudflare deployment ## Commands diff --git a/README.md b/README.md index d82580027..1463a95e6 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ 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. -The `stdio` transport is the `sentry mcp` command in the [`sentry` CLI](https://cli.sentry.dev). It uses your CLI login (`sentry auth login`). MCP client configurations written for the retired `@sentry/mcp-server` package keep working: `--access-token` and `SENTRY_ACCESS_TOKEN` still pass a static token, and every other flag is unchanged. +The `stdio` transport is the `sentry mcp` command in the [`sentry` CLI](https://cli.sentry.dev). It uses your CLI login (`sentry auth login`), or a static token from `--access-token` / `SENTRY_ACCESS_TOKEN`. The same server is also published on its own as `@sentry/mcp-server` (`npx -y @sentry/mcp-server`): one bundled file without the rest of the CLI, with the same flags and self-hosted support. To use a static token, create a User Auth Token in Sentry with the necessary scopes. As of writing this is: diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 9706c430c..f851fe224 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -15,7 +15,7 @@ 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/ # Deprecated shim that runs `sentry mcp` +├── mcp-server/ # `sentry mcp` bundled as @sentry/mcp-server ├── mcp-cloudflare/ # Cloudflare Workers deployment ├── mcp-server-evals/ # Evaluation test suite ├── mcp-server-mocks/ # Shared mock data @@ -65,11 +65,11 @@ The local stdio MCP server is the `sentry mcp` command in the Sentry CLI. - `src/lib/mcp/` parses server flags and environment variables, configures the embedded agent provider and telemetry, and starts mcp-core's stdio transport. -### packages/mcp-server (deprecated) +### packages/mcp-server -A deprecation shim for one last `@sentry/mcp-server` release. Its `sentry-mcp` -bin prints a notice and runs `sentry mcp` with the same arguments. Delete it after -the final publish; see [stdio release](../releases/stdio.md). +Publishes `sentry mcp` as `@sentry/mcp-server`: 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 diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index 3becdf3d9..bfdc9602e 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -10,9 +10,8 @@ credentials, so `sentry auth login`, `sentry auth logout`, and In order of precedence: -1. **Static token** — `--access-token` flag, then `SENTRY_ACCESS_TOKEN`. Kept - for MCP client configurations written for the retired `@sentry/mcp-server` - package. The token is used as given for the life of the server and is never +1. **Static token** — `--access-token` flag, then `SENTRY_ACCESS_TOKEN`. The + token is used as given for the life of the server and is never refreshed. Org auth tokens (`sntrys_`) record the Sentry URL they were issued for; `sentry mcp` refuses to send one to a different host (`HostScopeError`). `SENTRY_ACCESS_TOKEN` only affects `sentry mcp`; other @@ -41,8 +40,9 @@ The standalone package's device code flow, bundled client ID, and token cache read the old cache. Users who relied on it run `sentry auth login` once, or keep passing `--access-token` / `SENTRY_ACCESS_TOKEN`. -The final `@sentry/mcp-server` release is a shim that maps -`sentry-mcp auth login|logout|status` to `sentry auth login|logout|status`. +`@sentry/mcp-server` now bundles this same server and shares the CLI's +credential store. `sentry-mcp auth login` runs the CLI's device flow; use +`npx -y sentry auth logout|status` for the rest. ## Key Files diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index 87747db3b..29fa28eae 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -6,8 +6,8 @@ How the local stdio MCP server ships. The stdio MCP server is the `sentry mcp` command in the `sentry` npm package (`packages/cli`). It ships with every CLI release (the `packages/cli` Craft -workspace in `.craft.yml`); there is no separate MCP package to version or -publish. +workspace in `.craft.yml`). The same server also ships on its own as +`@sentry/mcp-server` (see below). `sentry mcp` bundles `@sentry/mcp-core` (tools, server, and the stdio transport) and uses the CLI's own authentication: @@ -15,27 +15,30 @@ transport) and uses the CLI's own authentication: - `sentry auth login` session (refreshed automatically, including after an upstream 401) - `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`, as for every CLI command -- `--access-token` / `SENTRY_ACCESS_TOKEN`: a static token for `sentry mcp` - only, kept for configurations written for the retired `@sentry/mcp-server` - package. It is never refreshed, and org auth tokens (`sntrys_`) are still +- `--access-token` / `SENTRY_ACCESS_TOKEN`: a static token for the MCP server + only. It is never refreshed, and org auth tokens (`sntrys_`) are still scoped to the host they were issued for. -## Retired `@sentry/mcp-server` package +## `@sentry/mcp-server` package -`packages/mcp-server` is a deprecation shim kept for one last release. Its -`sentry-mcp` bin prints a one-line stderr notice and runs `sentry mcp` with the -same arguments (`auth login|logout|status` map to `sentry auth ...`). It depends -on `sentry` (`>=0.48.0`, the first release with full flag parity), so publish -it only after that CLI release is on npm. +`packages/mcp-server` publishes the same server as a single file with no +runtime dependencies. `packages/cli/script/bundle-mcp-server.ts` bundles +`packages/cli/src/mcp-server.ts` into `dist/sentry-mcp.cjs`: -Remaining maintainer steps: +- Same flags, environment variables, tools, and self-hosted support as + `sentry mcp`, and the same CLI session and credential store. +- `sentry-mcp auth login` runs the CLI's device flow with plain-text output. + Other `auth` subcommands point to `npx -y sentry auth ...`. +- CLI-only code stays out: commands, the markdown renderer, agent skills, + completions, terminal images, the Ink UI, and debug-file processing. The + SQLite WASM fallback is left out too, so the package needs Node.js 22.15+. + The bundle script fails if any of these reach the output or the file grows + past its size budget. -1. Release `@sentry/mcp-server` once more through the `packages/mcp-server` - Craft workspace (`release/mcp` branches). -2. Deprecate it on npm with `npm deprecate @sentry/mcp-server ""`, - pointing users at `npx -y sentry mcp`. -3. Delete `packages/mcp-server`, its Craft workspace, and the pack step in - `.github/workflows/merge-jobs.yml`. +`pnpm --filter @sentry/mcp-server run bundle` builds it and `run test:bundle` +smoke-tests the result. The `packages/mcp-server` Craft workspace releases it +from `release/mcp` branches; `.github/workflows/merge-jobs.yml` bundles, tests, +and packs it. ## MCP Registry @@ -92,4 +95,5 @@ pnpm -w run cli --transport stdio "who am I?" - Command entry: `packages/cli/src/lib/mcp.ts` - Host (flags, env, telemetry): `packages/cli/src/lib/mcp/` - stdio transport: `packages/mcp-core/src/transports/stdio.ts` -- Deprecation shim: `packages/mcp-server/` +- `@sentry/mcp-server` entry: `packages/cli/src/mcp-server.ts` +- `@sentry/mcp-server` bundle: `packages/cli/script/bundle-mcp-server.ts` diff --git a/package.json b/package.json index b3e077338..3e4a710b1 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "scripts": { "docs:check": "node scripts/check-doc-links.mjs", - "test:ci-projects": "node --test scripts/ci-projects.test.mjs scripts/check-workspace-deps.test.mjs scripts/cli-nightly-version.test.mjs scripts/cli-nightly-watchdog.test.mjs scripts/cloudflare-deployment.test.mjs scripts/deploy-workflow.test.mjs scripts/migrate-cloudflare-token.test.mjs scripts/oxc-config.test.mjs scripts/cli-lint-rules.test.mjs packages/mcp-server/lib/shim.test.js", + "test:ci-projects": "node --test scripts/ci-projects.test.mjs scripts/check-workspace-deps.test.mjs scripts/cli-nightly-version.test.mjs scripts/cli-nightly-watchdog.test.mjs scripts/cloudflare-deployment.test.mjs scripts/deploy-workflow.test.mjs scripts/migrate-cloudflare-token.test.mjs scripts/oxc-config.test.mjs scripts/cli-lint-rules.test.mjs", "dev": "pnpm --filter '@sentry/mcp-cloudflare...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-cloudflare --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", "dev:stdio": "pnpm --filter '@sentry/mcp-core...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", "build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build", diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 24595dcd5..cd2319c21 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -113,6 +113,10 @@ Top-level layout: - **`src/bin.ts`** — entry point; **`src/app.ts`** — Stricli application setup; **`src/context.ts`** — dependency-injection context. +- **`src/mcp-server.ts`** — entry point for the standalone `@sentry/mcp-server` + bundle (`script/bundle-mcp-server.ts`). It must not import anything that + only the CLI needs; the bundle script fails on commands, markdown rendering, + agent skills, completions, terminal images, and the SQLite WASM fallback. - **`src/commands/`** — one directory per command group (`agent-conversation`, `alert`, `auth`, `build`, `cli`, `code-mappings`, `dart-symbol-map`, `dashboard`, `debug-files`, `docs`, `dsn`, `event`, `feedback`, `integration`, `issue`, `local`, diff --git a/packages/cli/package.json b/packages/cli/package.json index 4a4611683..619606f78 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,6 +62,7 @@ "build": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", + "bundle:mcp-server": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle-mcp-server.ts", "typecheck": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit", "lint": "pnpm -w exec oxlint --report-unused-disable-directives packages/cli && pnpm -w exec oxfmt --check packages/cli", "lint:fix": "pnpm -w exec oxlint --fix packages/cli && pnpm -w exec oxfmt --write packages/cli", diff --git a/packages/cli/script/bundle-mcp-server.ts b/packages/cli/script/bundle-mcp-server.ts new file mode 100644 index 000000000..1f3e4e42c --- /dev/null +++ b/packages/cli/script/bundle-mcp-server.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env tsx +/** + * Bundle `src/mcp-server.ts` into the standalone `@sentry/mcp-server` package. + * + * The package runs the same server as `sentry mcp` but ships only what the + * server needs: the MCP tools, the API client, and the CLI's auth. The build + * fails if anything on {@link FORBIDDEN_INPUTS} reaches the bundle or the + * output grows past {@link MAX_BUNDLE_BYTES}, so CLI-only code cannot creep in + * through a new import. + */ +import { mkdir, readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build, type Metafile, type Plugin } from "esbuild"; +import { requireAliasPlugin } from "./lib/require-alias-plugin.js"; +import { textImportPlugin } from "./text-import-plugin.js"; + +const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const PACKAGE_ROOT = resolve(CLI_ROOT, "../mcp-server"); +const OUTFILE = resolve(PACKAGE_ROOT, "dist/sentry-mcp.cjs"); + +/** + * Current output is about 3.6 MB. The budget leaves room for new tools without + * letting a heavy CLI dependency slip in unnoticed. + */ +const MAX_BUNDLE_BYTES = 4.5 * 1024 * 1024; + +/** + * The login flow's only markdown use is two inline hints, so the bundle swaps + * the renderer for a plain-text version. esbuild fails the build if the swap + * lacks an export that some importer needs. + */ +const MARKDOWN_MODULE = resolve(CLI_ROOT, "src/lib/formatters/markdown.ts"); +const PLAIN_MARKDOWN_MODULE = resolve( + CLI_ROOT, + "src/lib/formatters/markdown-plain.ts", +); + +/** + * Inputs that serve the CLI but not the MCP server: CLI commands, markdown + * rendering, agent skills, shell completions, terminal images, the Ink UI, + * `sentry init`, build/sourcemap/debug-file processing, and the SQLite WASM + * fallback for Node.js < 22.15. + */ +const FORBIDDEN_INPUTS: ReadonlyArray = [ + /[\\/]packages[\\/]cli[\\/]src[\\/](?:app\.ts|commands[\\/])/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:agent-skills|banner|complete|completion-target|completions|kitty-image|sixel|sixel-image)\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/]formatters[\\/](?:markdown|pixel-canvas|sixel-dashboard|sixel-timeseries)\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:bash-hook|build|dif|init|react-native|snapshots|sourcemap|wasm)[\\/]/, + /[\\/]node_modules[\\/](?:cli-highlight|highlight\.js|marked|parse5|ink|ink-spinner|react|react-reconciler|yoga-layout|@sentry[\\/]symbolic|node-sqlite3-wasm)[\\/]/, +]; + +/** The bundle requires native `node:sqlite`, which Node.js 22.15 unflagged. */ +const NODE_VERSION_CHECK = `{let v=process.versions.node.split(".").map(Number);if(v[0]<22||(v[0]===22&&v[1]<15)){console.error("Error: @sentry/mcp-server requires Node.js 22.15 or later (found "+process.version+").");process.exit(1)}}`; + +const plainMarkdownPlugin: Plugin = { + name: "plain-markdown", + setup(b) { + b.onResolve({ filter: /[\\/]markdown\.js$/ }, (args) => + resolve(args.resolveDir, args.path.replace(/\.js$/, ".ts")) === + MARKDOWN_MODULE + ? { path: PLAIN_MARKDOWN_MODULE } + : undefined, + ); + }, +}; + +/** Inputs on {@link FORBIDDEN_INPUTS} that contribute code to the output. */ +function findForbiddenInputs(metafile: Metafile): string[] { + return Object.values(metafile.outputs) + .flatMap((output) => Object.entries(output.inputs)) + .filter(([, { bytesInOutput }]) => bytesInOutput > 0) + .map(([input]) => resolve(CLI_ROOT, input)) + .filter((input) => FORBIDDEN_INPUTS.some((pattern) => pattern.test(input))); +} + +const pkg = JSON.parse( + await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8"), +) as { version: string }; +const cliPkg = JSON.parse( + await readFile(resolve(CLI_ROOT, "package.json"), "utf-8"), +) as { version: string }; + +console.log(`\nBundling @sentry/mcp-server v${pkg.version}`); + +await mkdir(dirname(OUTFILE), { recursive: true }); +const result = await build({ + absWorkingDir: CLI_ROOT, + entryPoints: ["./src/mcp-server.ts"], + outfile: OUTFILE, + bundle: true, + minify: true, + treeShaking: true, + platform: "node", + target: "node22", + format: "cjs", + banner: { js: `#!/usr/bin/env node\n${NODE_VERSION_CHECK}` }, + inject: ["./script/import-meta-url.js"], + define: { + SENTRY_CLI_VERSION: JSON.stringify(cliPkg.version), + SENTRY_MCP_SERVER_VERSION: JSON.stringify(pkg.version), + // Without it, the CLI falls back to its committed default client ID. + ...(process.env.SENTRY_CLIENT_ID + ? { SENTRY_CLIENT_ID_BUILD: JSON.stringify(process.env.SENTRY_CLIENT_ID) } + : {}), + "process.env.NODE_ENV": JSON.stringify("production"), + // Tree-shakes the SDK's runtime module hooks, which can't instrument a + // bundle. + __SENTRY_CHANNEL_INJECTION__: "false", + "import.meta.url": "import_meta_url", + }, + external: [ + "node:*", + // Only reachable on Node.js < 22.15, which the version check rejects. + "node-sqlite3-wasm", + "@sentry/symbolic/symbolic_bg.wasm", + "ink", + "ink-spinner", + "react", + "react/*", + "react-reconciler", + "react-reconciler/*", + "react-devtools-core", + "yoga-layout", + ], + metafile: true, + plugins: [plainMarkdownPlugin, textImportPlugin, requireAliasPlugin], +}); + +const forbidden = findForbiddenInputs(result.metafile); +if (forbidden.length > 0) { + console.error( + "\nError: CLI-only modules reached the @sentry/mcp-server bundle:", + ); + for (const input of forbidden) { + console.error(` ${input}`); + } + console.error("Keep these imports out of the MCP server's import graph.\n"); + process.exit(1); +} + +const bytes = Object.values(result.metafile.outputs).reduce( + (total, output) => total + output.bytes, + 0, +); +if (bytes > MAX_BUNDLE_BYTES) { + console.error( + `\nError: the @sentry/mcp-server bundle is ${(bytes / 1024 / 1024).toFixed(2)} MB, over the ${(MAX_BUNDLE_BYTES / 1024 / 1024).toFixed(1)} MB budget.\n`, + ); + process.exit(1); +} + +console.log( + ` -> ${OUTFILE.slice(PACKAGE_ROOT.length + 1)} (${(bytes / 1024).toFixed(1)} KB)`, +); diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index d6624cfca..94ac1c412 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -7,6 +7,7 @@ import pkg from "../package.json"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; import { buildCoreDeclarations, SDK_TYPES_PATH } from "./sdk-declarations.js"; +import { requireAliasPlugin } from "./lib/require-alias-plugin.js"; import { textImportPlugin } from "./text-import-plugin.js"; const VERSION = pkg.version; @@ -158,39 +159,12 @@ const sentrySourcemapPlugin: Plugin = { }; // Always inject debug IDs (even without auth token); upload is gated inside the plugin -/** - * Files that use `_require()` for lazy imports. The `require-alias` plugin - * rewrites `_require(` → `require(` in these so esbuild resolves them at - * bundle time. `db/sqlite` is included so the WASM SQLite fallback - * (`node-sqlite3-wasm`) is actually inlined into the npm bundle — otherwise - * it stays a runtime `require()` that fails in a real install (the driver is - * a devDependency, not shipped). `node:sqlite` in the same file stays a - * builtin and is left external by esbuild regardless. - * `formatters/json` loads its generated parser only when --fields is parsed. - */ -const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/]json|list-command|telemetry)\.ts$/; -const REQUIRE_ALIAS_RE = /\b_require\(/g; const IDENTIFIER_FILTER_RE = /^[A-Za-z_$][\w$]*$/; // Match both the prefixed and bare specifiers — bundled dependencies (e.g. // binpatch) import from "zlib", which esbuild also treats as external builtin. const NODE_ZLIB_RE = /^(?:node:)?zlib$/; const ANY_RE = /.*/; -/** Transform _require() → require() so esbuild resolves lazy relative requires. */ -const requireAliasPlugin: Plugin = { - name: "require-alias", - setup(b) { - b.onLoad({ filter: REQUIRE_ALIAS_FILTER }, async (args) => { - const source = await readFile(args.path, "utf-8"); - return { - contents: source.replace(REQUIRE_ALIAS_RE, "require("), - loader: args.path.endsWith(".tsx") ? "tsx" : "ts", - }; - }); - }, -}; - /** * ESM-only: make `node:zlib` imports resolve through a namespace shim so that * named imports of version-gated exports (the `zstd*` family, added in Node diff --git a/packages/cli/script/lib/require-alias-plugin.ts b/packages/cli/script/lib/require-alias-plugin.ts new file mode 100644 index 000000000..6b6f927f7 --- /dev/null +++ b/packages/cli/script/lib/require-alias-plugin.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import type { Plugin } from "esbuild"; + +/** + * Files that use `_require()` for lazy imports. The `require-alias` plugin + * rewrites `_require(` → `require(` in these so esbuild resolves them at + * bundle time. `db/sqlite` is included so the WASM SQLite fallback + * (`node-sqlite3-wasm`) is actually inlined into the npm bundle — otherwise + * it stays a runtime `require()` that fails in a real install (the driver is + * a devDependency, not shipped). `node:sqlite` in the same file stays a + * builtin and is left external by esbuild regardless. + * `formatters/json` loads its generated parser only when --fields is parsed. + */ +const REQUIRE_ALIAS_FILTER = + /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/]json|list-command|telemetry)\.ts$/; +const REQUIRE_ALIAS_RE = /\b_require\(/g; + +/** Transform _require() → require() so esbuild resolves lazy relative requires. */ +export const requireAliasPlugin: Plugin = { + name: "require-alias", + setup(b) { + b.onLoad({ filter: REQUIRE_ALIAS_FILTER }, async (args) => { + const source = await readFile(args.path, "utf-8"); + return { + contents: source.replace(REQUIRE_ALIAS_RE, "require("), + loader: args.path.endsWith(".tsx") ? "tsx" : "ts", + }; + }); + }, +}; diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index e7821f6ec..409dd66b5 100755 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -5,22 +5,8 @@ * CLI-specific — the library uses captured Writers that don't have real streams. */ -// Suppress "ExperimentalWarning: SQLite is an experimental feature" from -// node:sqlite. Must run before any import triggers the warning. -const _origEmit = process.emit.bind(process) as typeof process.emit; -process.emit = ((event: string, ...args: unknown[]) => { - if ( - event === "warning" && - args[0] instanceof Error && - args[0].name === "ExperimentalWarning" && - args[0].message.includes("SQLite") - ) { - return false; - } - // @ts-expect-error: forwarding args to original emit - return _origEmit(event, ...args); -}) as typeof process.emit; - +// Must stay the first import: later imports can load node:sqlite. +import "./lib/suppress-sqlite-warning.js"; import { startCli } from "./cli.js"; import { wrapCall } from "./lib/react-native/wrap-call.js"; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f0285589e..6e8bba24f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,59 +14,9 @@ import { redactCredentialText } from "./lib/credential-redaction.js"; import { getEnv } from "./lib/env.js"; import { CliError, formatError } from "./lib/errors.js"; import { buildTopLevelFlags } from "./lib/global-flags.js"; +import { preloadProjectContext } from "./lib/project-context.js"; import { initTimezone } from "./lib/timezone.js"; -/** - * Preload project context: walk up from `cwd` once, finding both the - * project root (for DSN detection) and `.sentryclirc` config (for - * org/project defaults and env shim). Caches both results so later calls - * to `findProjectRoot` and `loadSentryCliRc` are cache hits. - */ -async function preloadProjectContext(cwd: string): Promise { - // Snapshot env-token host BEFORE anything mutates env.SENTRY_HOST/URL - // (the .sentryclirc shim or the default-URL fallback below). Pins the - // env-token's trust scope to the user's shell, not a repo-local file. - // Dynamic import: env-token-host chains into db/auth → telemetry → @sentry/node - const { captureEnvTokenHost } = await import("./lib/env-token-host.js"); - captureEnvTokenHost(); - - // Dynamic import keeps the heavy DSN/DB modules out of the completion fast-path - const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([ - import("./lib/dsn/project-root.js"), - import("./lib/db/project-root-cache.js"), - ]); - - const result = await findProjectRoot(cwd); - await setCachedProjectRoot(cwd, { - projectRoot: result.projectRoot, - reason: result.reason, - }); - - // Apply .sentryclirc env shim (token + URL). The URL trust check is - // deferred to buildCommand's wrapper where commands can opt out via - // skipRcUrlCheck (used by auth login/logout). - // Dynamic import: sentryclirc chains into db/index → sqlite, logger → consola - const { applySentryCliRcEnvShim } = await import("./lib/sentryclirc.js"); - await applySentryCliRcEnvShim(cwd); - - // Apply persistent URL default (lower priority than env vars and .sentryclirc). - const env = getEnv(); - if (!(env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim())) { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - try { - const { getDefaultUrl } = await import("./lib/db/defaults.js"); - const url = getDefaultUrl(); - if (url) { - env.SENTRY_URL = url; - const { markPersistedDefaultUrl } = await import("./lib/constants.js"); - markPersistedDefaultUrl(url); - } - } catch { - // DB not available — skip - } - } -} - /** * Fast-path: shell completion. * @@ -283,28 +233,8 @@ export async function runMcpCommand(cliArgs: string[]): Promise { return false; } - const [{ startMcpServer }, { getExitCode }] = await Promise.all([ - import("./lib/mcp.js"), - import("./lib/errors.js"), - ]); - - try { - await startMcpServer(mcpArgs); - } catch (mcpError) { - process.stderr.write(`Error: ${formatError(mcpError)}\n`); - process.exitCode = getExitCode(mcpError); - // MCP setup errors are terminal, unlike a running stdio server. Clean up - // network resources only on this error path so successful servers retain - // their dispatcher and are not force-exited on macOS. - const [{ scheduleForceExit }, { closeGlobalDispatcher }] = - await Promise.all([ - import("./lib/force-exit.js"), - import("./lib/close-dispatcher.js"), - ]); - scheduleForceExit(); - await closeGlobalDispatcher(); - } - + const { runMcpServer } = await import("./lib/mcp/run.js"); + await runMcpServer(mcpArgs); return true; } diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 3031fb2ad..a22e19583 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -69,7 +69,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_ACCESS_TOKEN", description: - "Static auth token for `sentry mcp`, kept for MCP client configurations written for the retired `@sentry/mcp-server` package (the env form of its `--access-token` flag). It replaces the CLI session for the MCP server only and is never refreshed. Other commands ignore it.", + "Static auth token for `sentry mcp` and `@sentry/mcp-server` (the env form of the `--access-token` flag). It replaces the CLI session for the MCP server only and is never refreshed. Other commands ignore it.", example: "sntryu_YOUR_TOKEN_HERE", }, // -- Targeting -- diff --git a/packages/cli/src/lib/formatters/markdown-plain.ts b/packages/cli/src/lib/formatters/markdown-plain.ts new file mode 100644 index 000000000..9d4dfcd53 --- /dev/null +++ b/packages/cli/src/lib/formatters/markdown-plain.ts @@ -0,0 +1,10 @@ +/** + * Plain-text stand-in for `markdown.ts` in the standalone `@sentry/mcp-server` + * bundle. The markdown renderer and its syntax highlighter would add over a + * megabyte for the two inline hints the login flow prints. + */ + +/** Strip inline markdown emphasis and code markers. */ +export function renderInlineMarkdown(md: string): string { + return (md ?? "").replace(/[`*]/g, ""); +} diff --git a/packages/cli/src/lib/mcp/args.ts b/packages/cli/src/lib/mcp/args.ts index d3dac31fb..2349fcfe7 100644 --- a/packages/cli/src/lib/mcp/args.ts +++ b/packages/cli/src/lib/mcp/args.ts @@ -1,9 +1,9 @@ /** - * Argument and environment parsing for `sentry mcp`. + * Argument and environment parsing for `sentry mcp` and `@sentry/mcp-server`. * - * Accepts the same flags and environment variables as the retired - * `@sentry/mcp-server` package, so existing MCP client configurations keep - * working when they switch to `sentry mcp`. Flags win over environment values. + * Both accept the flags and environment variables that `@sentry/mcp-server` + * has always taken, so existing MCP client configurations keep working. Flags win + * over environment values. */ import { parseArgs } from "node:util"; diff --git a/packages/cli/src/lib/mcp/auth.ts b/packages/cli/src/lib/mcp/auth.ts new file mode 100644 index 000000000..59f02e10e --- /dev/null +++ b/packages/cli/src/lib/mcp/auth.ts @@ -0,0 +1,34 @@ +/** + * `sentry-mcp auth` for the standalone `@sentry/mcp-server` bundle. + * + * Only `login` is built in, so the package can authenticate without the CLI. + * Account management stays in `sentry auth`, which uses the same session. + */ + +import { assertAutoLoginHostTrusted } from "../auto-auth.js"; +import { ValidationError } from "../errors.js"; +import { runInteractiveLogin } from "../interactive-login.js"; + +/** Run `sentry-mcp auth [login]`. */ +export async function runMcpServerAuth(args: readonly string[]): Promise { + const [subcommand = "login", ...rest] = args; + if (subcommand !== "login" || rest.length > 0) { + throw new ValidationError( + `\`sentry-mcp auth ${args.join(" ")}\` is not supported. ` + + `Run \`npx -y sentry auth ${args.join(" ")}\` instead; it uses the same session.`, + ); + } + + // Never start an OAuth device flow against an unconfirmed self-hosted host. + assertAutoLoginHostTrusted(); + + const result = await runInteractiveLogin(); + if (!result) { + process.exitCode = 1; + return; + } + const identity = result.user?.email ?? result.user?.username; + process.stderr.write( + identity ? `Logged in as ${identity}.\n` : "Logged in.\n", + ); +} diff --git a/packages/cli/src/lib/mcp/run.ts b/packages/cli/src/lib/mcp/run.ts new file mode 100644 index 000000000..7adadbfd6 --- /dev/null +++ b/packages/cli/src/lib/mcp/run.ts @@ -0,0 +1,27 @@ +/** + * Run the stdio MCP server and handle setup failures. + * + * Shared by `sentry mcp` and the standalone `@sentry/mcp-server` bundle. + */ + +import { formatError, getExitCode } from "../errors.js"; +import { startMcpServer } from "../mcp.js"; + +export async function runMcpServer(args: string[]): Promise { + try { + await startMcpServer(args); + } catch (mcpError) { + process.stderr.write(`Error: ${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("../force-exit.js"), + import("../close-dispatcher.js"), + ]); + scheduleForceExit(); + await closeGlobalDispatcher(); + } +} diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 693aab4c5..2e74c94de 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -29,10 +29,9 @@ import { vercelAIIntegration, zodErrorsIntegration, } from "@sentry/node"; -import { CLI_VERSION } from "../constants.js"; import { mergeMcpArgs, parseMcpArgv, parseMcpEnv } from "./args.js"; import { finalizeMcpConfig, type McpServerConfig } from "./config.js"; -import { buildMcpUsage } from "./usage.js"; +import { buildMcpUsage, buildMcpVersion } from "./usage.js"; /** What a `sentry mcp` invocation asks for. */ export type McpInvocation = @@ -62,7 +61,7 @@ export function parseMcpInvocation( return { kind: "output", text: buildMcpUsage(ALL_SKILLS) }; } if (cli.version) { - return { kind: "output", text: `sentry mcp ${CLI_VERSION}` }; + return { kind: "output", text: buildMcpVersion() }; } return { kind: "serve", diff --git a/packages/cli/src/lib/mcp/usage.ts b/packages/cli/src/lib/mcp/usage.ts index 7e927453b..c4e77ffc5 100644 --- a/packages/cli/src/lib/mcp/usage.ts +++ b/packages/cli/src/lib/mcp/usage.ts @@ -1,15 +1,32 @@ -/** Help text for `sentry mcp --help`. */ +/** Help and version text for `sentry mcp` and `sentry-mcp`. */ import type { Skill } from "@sentry/mcp-core/skills"; +import { CLI_VERSION } from "../constants.js"; -const COMMAND = "sentry mcp"; +/** Set by `script/bundle-mcp-server.ts` to the `@sentry/mcp-server` version. */ +declare const SENTRY_MCP_SERVER_VERSION: string | undefined; -/** Build the `sentry mcp --help` text. */ +/** Whether this code runs as the standalone `@sentry/mcp-server` bundle. */ +const IS_MCP_SERVER_PACKAGE = typeof SENTRY_MCP_SERVER_VERSION !== "undefined"; + +const COMMAND = IS_MCP_SERVER_PACKAGE ? "sentry-mcp" : "sentry mcp"; +const LOGIN_COMMAND = IS_MCP_SERVER_PACKAGE + ? "sentry-mcp auth login" + : "sentry auth login"; + +/** Build the `--version` output. */ +export function buildMcpVersion(): string { + return IS_MCP_SERVER_PACKAGE + ? `${COMMAND} ${SENTRY_MCP_SERVER_VERSION}` + : `${COMMAND} ${CLI_VERSION}`; +} + +/** Build the `--help` text. */ export function buildMcpUsage(allSkills: ReadonlyArray): string { return `Usage: ${COMMAND} [--access-token=] [--host=] [--insecure-http] Authentication: - Uses the active Sentry CLI session by default. Authenticate with \`sentry auth login\`. + Uses the active Sentry CLI session by default. Authenticate with \`${LOGIN_COMMAND}\`. --access-token Use this Sentry auth token instead of the CLI session (never refreshed; org auth tokens must match the selected host) diff --git a/packages/cli/src/lib/project-context.ts b/packages/cli/src/lib/project-context.ts new file mode 100644 index 000000000..4bb08ec24 --- /dev/null +++ b/packages/cli/src/lib/project-context.ts @@ -0,0 +1,56 @@ +/** + * Project context loading shared by every entry point that runs CLI code. + */ + +import { getEnv } from "./env.js"; + +/** + * Preload project context: walk up from `cwd` once, finding both the + * project root (for DSN detection) and `.sentryclirc` config (for + * org/project defaults and env shim). Caches both results so later calls + * to `findProjectRoot` and `loadSentryCliRc` are cache hits. + */ +export async function preloadProjectContext(cwd: string): Promise { + // Snapshot env-token host BEFORE anything mutates env.SENTRY_HOST/URL + // (the .sentryclirc shim or the default-URL fallback below). Pins the + // env-token's trust scope to the user's shell, not a repo-local file. + // Dynamic import: env-token-host chains into db/auth → telemetry → @sentry/node + const { captureEnvTokenHost } = await import("./env-token-host.js"); + captureEnvTokenHost(); + + // Dynamic import keeps the heavy DSN/DB modules out of the completion fast-path + const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([ + import("./dsn/project-root.js"), + import("./db/project-root-cache.js"), + ]); + + const result = await findProjectRoot(cwd); + await setCachedProjectRoot(cwd, { + projectRoot: result.projectRoot, + reason: result.reason, + }); + + // Apply .sentryclirc env shim (token + URL). The URL trust check is + // deferred to buildCommand's wrapper where commands can opt out via + // skipRcUrlCheck (used by auth login/logout). + // Dynamic import: sentryclirc chains into db/index → sqlite, logger → consola + const { applySentryCliRcEnvShim } = await import("./sentryclirc.js"); + await applySentryCliRcEnvShim(cwd); + + // Apply persistent URL default (lower priority than env vars and .sentryclirc). + const env = getEnv(); + if (!(env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim())) { + // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. + try { + const { getDefaultUrl } = await import("./db/defaults.js"); + const url = getDefaultUrl(); + if (url) { + env.SENTRY_URL = url; + const { markPersistedDefaultUrl } = await import("./constants.js"); + markPersistedDefaultUrl(url); + } + } catch { + // DB not available — skip + } + } +} diff --git a/packages/cli/src/lib/suppress-sqlite-warning.ts b/packages/cli/src/lib/suppress-sqlite-warning.ts new file mode 100644 index 000000000..4182ac319 --- /dev/null +++ b/packages/cli/src/lib/suppress-sqlite-warning.ts @@ -0,0 +1,18 @@ +/** + * Drop node:sqlite's "SQLite is an experimental feature" warning. Import this + * module first in each entry point, before anything loads node:sqlite. + */ + +const originalEmit = process.emit.bind(process) as typeof process.emit; +process.emit = ((event: string, ...args: unknown[]) => { + if ( + event === "warning" && + args[0] instanceof Error && + args[0].name === "ExperimentalWarning" && + args[0].message.includes("SQLite") + ) { + return false; + } + // @ts-expect-error: forwarding args to original emit + return originalEmit(event, ...args); +}) as typeof process.emit; diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts new file mode 100644 index 000000000..4f9f48c7d --- /dev/null +++ b/packages/cli/src/mcp-server.ts @@ -0,0 +1,45 @@ +/** + * Entry point for the standalone `@sentry/mcp-server` bundle. + * + * Runs the same server as `sentry mcp` without the rest of the CLI, so the + * package stays small. Built by `script/bundle-mcp-server.ts`. + */ + +// Must stay the first import: later imports can load node:sqlite, whose +// warning MCP clients surface as a server error. +import "./lib/suppress-sqlite-warning.js"; +import { redactCredentialText } from "./lib/credential-redaction.js"; +import { CliError, formatError, getExitCode } from "./lib/errors.js"; +import { preloadProjectContext } from "./lib/project-context.js"; + +async function main(args: string[]): Promise { + try { + await preloadProjectContext(process.cwd()); + } catch (err) { + if (err instanceof CliError) { + process.stderr.write(`${formatError(err)}\n`); + process.exitCode = err.exitCode; + return; + } + // Project context is optional; a missing or unreadable rc file is fine. + } + + if (args[0] === "auth") { + const { runMcpServerAuth } = await import("./lib/mcp/auth.js"); + try { + await runMcpServerAuth(args.slice(1)); + } catch (err) { + process.stderr.write(`Error: ${formatError(err)}\n`); + process.exitCode = getExitCode(err); + } + return; + } + + const { runMcpServer } = await import("./lib/mcp/run.js"); + await runMcpServer(args); +} + +main(process.argv.slice(2)).catch((err) => { + process.stderr.write(`Fatal: ${redactCredentialText(String(err))}\n`); + process.exitCode = 1; +}); diff --git a/packages/cli/test/lib/formatters/markdown-plain.test.ts b/packages/cli/test/lib/formatters/markdown-plain.test.ts new file mode 100644 index 000000000..33e0299d8 --- /dev/null +++ b/packages/cli/test/lib/formatters/markdown-plain.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { renderInlineMarkdown } from "../../../src/lib/formatters/markdown-plain.js"; + +describe("renderInlineMarkdown (plain)", () => { + test("drops code and emphasis markers", () => { + expect(renderInlineMarkdown(" Code: `ABCD-EFGH`")).toBe( + " Code: ABCD-EFGH", + ); + expect(renderInlineMarkdown(" *(`c` to copy URL)*")).toBe( + " (c to copy URL)", + ); + }); +}); diff --git a/packages/cli/test/lib/mcp/auth.test.ts b/packages/cli/test/lib/mcp/auth.test.ts new file mode 100644 index 000000000..cb790d2cb --- /dev/null +++ b/packages/cli/test/lib/mcp/auth.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ValidationError } from "../../../src/lib/errors.js"; + +const assertAutoLoginHostTrusted = vi.fn(); +const runInteractiveLogin = vi.fn(); + +vi.mock("../../../src/lib/auto-auth.js", () => ({ + assertAutoLoginHostTrusted: () => assertAutoLoginHostTrusted(), +})); +vi.mock("../../../src/lib/interactive-login.js", () => ({ + runInteractiveLogin: (...args: unknown[]) => runInteractiveLogin(...args), +})); + +const { runMcpServerAuth } = await import("../../../src/lib/mcp/auth.js"); + +const originalExitCode = process.exitCode; +let stderr: string; + +beforeEach(() => { + assertAutoLoginHostTrusted.mockReset(); + runInteractiveLogin.mockReset(); + stderr = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = originalExitCode; +}); + +describe("runMcpServerAuth", () => { + test.each([[[]], [["login"]]])("logs in for %j", async (args) => { + runInteractiveLogin.mockResolvedValue({ + method: "oauth", + configPath: "/tmp/cli.db", + user: { email: "user@example.com" }, + }); + + await runMcpServerAuth(args); + + expect(assertAutoLoginHostTrusted).toHaveBeenCalledTimes(1); + expect(runInteractiveLogin).toHaveBeenCalledTimes(1); + expect(stderr).toBe("Logged in as user@example.com.\n"); + expect(process.exitCode).toBe(originalExitCode); + }); + + test("checks the host before starting the device flow", async () => { + assertAutoLoginHostTrusted.mockImplementation(() => { + throw new ValidationError("untrusted host"); + }); + + await expect(runMcpServerAuth(["login"])).rejects.toThrow("untrusted host"); + expect(runInteractiveLogin).not.toHaveBeenCalled(); + }); + + test("fails when the login does not complete", async () => { + runInteractiveLogin.mockResolvedValue(null); + + await runMcpServerAuth(["login"]); + + expect(process.exitCode).toBe(1); + }); + + test.each([[["status"]], [["logout"]], [["login", "--url=x"]]])( + "points %j to the CLI", + async (args) => { + await expect(runMcpServerAuth(args)).rejects.toThrow( + `npx -y sentry auth ${args.join(" ")}`, + ); + expect(runInteractiveLogin).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/cli/test/lib/mcp/usage-standalone.test.ts b/packages/cli/test/lib/mcp/usage-standalone.test.ts new file mode 100644 index 000000000..1f4368392 --- /dev/null +++ b/packages/cli/test/lib/mcp/usage-standalone.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe("standalone @sentry/mcp-server bundle", () => { + test("names `sentry-mcp` and reports the package version", async () => { + vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); + vi.resetModules(); + const { buildMcpUsage, buildMcpVersion } = + await import("../../../src/lib/mcp/usage.js"); + + expect(buildMcpVersion()).toBe("sentry-mcp 1.2.3"); + const usage = buildMcpUsage(["inspect"]); + expect(usage).toMatch(/^Usage: sentry-mcp /); + expect(usage).toContain("`sentry-mcp auth login`"); + expect(usage).not.toContain("sentry mcp"); + }); +}); diff --git a/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts b/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts index 558edcd4e..fce0b4444 100644 --- a/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts +++ b/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts @@ -77,7 +77,7 @@ describe("CVE: .sentryclirc URL credential exfiltration", () => { delete process.env.SENTRY_URL; // SENTRY_AUTH_TOKEN is already set by test/preload.ts // Capture env-token host BEFORE the shim mutates env (mirrors boot - // ordering in cli.ts::preloadProjectContext). + // ordering in lib/project-context.ts::preloadProjectContext). captureEnvTokenHost(); writeRc(testDir, "[defaults]\nurl = https://evil.com\n"); diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 534ae9e8a..ded5eec62 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -1,21 +1,35 @@ -# @sentry/mcp-server (deprecated) +# @sentry/mcp-server -The Sentry stdio MCP server now ships in the Sentry CLI. Configure your MCP -client to run: +The Sentry stdio MCP server as a single file with no runtime dependencies. It +is the Sentry CLI's `sentry mcp` server, bundled without the rest of the CLI, +so it works with sentry.io and self-hosted Sentry. ```json { "command": "npx", - "args": ["-y", "sentry", "mcp"] + "args": ["-y", "@sentry/mcp-server"] } ``` -`sentry mcp` accepts the same flags and environment variables, including -`--access-token` / `SENTRY_ACCESS_TOKEN`. Without a token it uses your -`sentry auth login` session. +Requires Node.js 22.15 or later. -This package is a thin shim that prints a deprecation notice and runs -`sentry mcp` with the same arguments. `sentry-mcp auth login|logout|status` -runs `sentry auth login|logout|status`. +## Authentication -To delete after the final `@sentry/mcp-server` publish. +- `npx -y @sentry/mcp-server auth login` signs in with the Sentry CLI's device + flow. The session is shared with the `sentry` CLI, so `sentry auth login` + works too. +- `--access-token` / `SENTRY_ACCESS_TOKEN` passes a static token instead. +- For account management (`logout`, `status`, switching accounts), use + `npx -y sentry auth ...`. + +## Options + +The flags and environment variables are the same as `sentry mcp`; run +`npx -y @sentry/mcp-server --help` for the list. For self-hosted Sentry, pass +`--host` or `--url`, or set `SENTRY_HOST` / `SENTRY_URL`. + +## Development + +The source lives in `packages/cli`. `pnpm run bundle` builds +`dist/sentry-mcp.cjs` from `packages/cli/src/mcp-server.ts`, and +`pnpm run test:bundle` smoke-tests the result. diff --git a/packages/mcp-server/bin/sentry-mcp.js b/packages/mcp-server/bin/sentry-mcp.js deleted file mode 100644 index d6d061c65..000000000 --- a/packages/mcp-server/bin/sentry-mcp.js +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import { run } from "../lib/shim.js"; - -run(process.argv.slice(2)); diff --git a/packages/mcp-server/lib/shim.js b/packages/mcp-server/lib/shim.js deleted file mode 100644 index b2dd270f9..000000000 --- a/packages/mcp-server/lib/shim.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Deprecation shim for `@sentry/mcp-server`. - * - * The stdio MCP server now ships in the Sentry CLI as `sentry mcp`. This - * package forwards every invocation to the `sentry` package so existing MCP - * client configurations keep working for one last release. - * - * To delete after the final `@sentry/mcp-server` publish. - */ -import { spawn } from "node:child_process"; -import { createRequire } from "node:module"; -import { constants } from "node:os"; -import { dirname, join } from "node:path"; - -export const DEPRECATION_NOTICE = - "@sentry/mcp-server is deprecated and now runs `sentry mcp`; update your MCP client to run `npx -y sentry mcp`."; - -/** - * Map `@sentry/mcp-server` arguments to `sentry` arguments. - * - * Server flags pass through to `sentry mcp` unchanged. The old `auth` - * subcommands map to the CLI's own `sentry auth` commands; bare `auth` meant - * `auth login`. - * - * @param {readonly string[]} args - * @returns {string[]} - */ -export function toSentryArgs(args) { - if (args[0] !== "auth") { - return ["mcp", ...args]; - } - const rest = args.slice(1); - return ["auth", ...(rest.length > 0 ? rest : ["login"])]; -} - -/** - * Locate the `sentry` package's bin script next to its main entry. - * - * @param {(id: string) => string} [resolve] - * @returns {string} - */ -export function resolveSentryBin( - resolve = createRequire(import.meta.url).resolve, -) { - return join(dirname(resolve("sentry")), "bin.cjs"); -} - -/** - * Run `sentry` with the mapped arguments and mirror its exit status. - * - * @param {readonly string[]} args - */ -export function run(args) { - process.stderr.write(`${DEPRECATION_NOTICE}\n`); - - const child = spawn( - process.execPath, - [resolveSentryBin(), ...toSentryArgs(args)], - { - stdio: "inherit", - }, - ); - for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)); - } - child.on("error", (error) => { - process.stderr.write(`Failed to start sentry: ${error.message}\n`); - process.exit(1); - }); - child.on("exit", (code, signal) => { - process.exit(code ?? 128 + (signal ? (constants.signals[signal] ?? 0) : 0)); - }); -} diff --git a/packages/mcp-server/lib/shim.test.js b/packages/mcp-server/lib/shim.test.js deleted file mode 100644 index d5885c671..000000000 --- a/packages/mcp-server/lib/shim.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import assert from "node:assert/strict"; -import { join } from "node:path"; -import { describe, it } from "node:test"; -import { resolveSentryBin, toSentryArgs } from "./shim.js"; - -describe("toSentryArgs", () => { - it("forwards server flags to `sentry mcp` unchanged", () => { - assert.deepEqual( - toSentryArgs(["--access-token=TOKEN", "--host", "sentry.example.com"]), - ["mcp", "--access-token=TOKEN", "--host", "sentry.example.com"], - ); - assert.deepEqual(toSentryArgs([]), ["mcp"]); - }); - - it("maps the old auth subcommands to `sentry auth`", () => { - assert.deepEqual(toSentryArgs(["auth", "login"]), ["auth", "login"]); - assert.deepEqual(toSentryArgs(["auth", "logout"]), ["auth", "logout"]); - assert.deepEqual(toSentryArgs(["auth", "status"]), ["auth", "status"]); - }); - - it("treats bare `auth` as `auth login`", () => { - assert.deepEqual(toSentryArgs(["auth"]), ["auth", "login"]); - }); -}); - -describe("resolveSentryBin", () => { - it("uses the bin script next to the sentry package entry", () => { - const entry = join("/deps", "sentry", "dist", "index.cjs"); - assert.equal( - resolveSentryBin((id) => { - assert.equal(id, "sentry"); - return entry; - }), - join("/deps", "sentry", "dist", "bin.cjs"), - ); - }); -}); diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index cce172856..e2659d66b 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,14 +1,18 @@ { "name": "@sentry/mcp-server", "version": "0.42.0", - "description": "Deprecated: use `sentry mcp` from the `sentry` package instead", + "description": "Sentry MCP server over stdio: the CLI's `sentry mcp` server, bundled without the rest of the CLI", "sentryCi": { - "enabled": false + "build": "bundle", + "test": "test:bundle", + "dependencies": [ + "sentry" + ] }, "type": "module", "packageManager": "pnpm@11.8.0", "engines": { - "node": ">=22.13" + "node": ">=22.15" }, "publishConfig": { "access": "public" @@ -29,16 +33,13 @@ "url": "git+https://github.com/getsentry/toolkit.git" }, "bin": { - "sentry-mcp": "./bin/sentry-mcp.js" + "sentry-mcp": "./dist/sentry-mcp.cjs" }, "files": [ - "bin/sentry-mcp.js", - "lib/shim.js" + "dist/sentry-mcp.cjs" ], "scripts": { - "test": "node --test lib/shim.test.js" - }, - "dependencies": { - "sentry": "workspace:>=0.48.0-dev.0" + "bundle": "pnpm --filter sentry run bundle:mcp-server", + "test:bundle": "node --test test/bundle.test.mjs" } } diff --git a/packages/mcp-server/test/bundle.test.mjs b/packages/mcp-server/test/bundle.test.mjs new file mode 100644 index 000000000..aeaa44955 --- /dev/null +++ b/packages/mcp-server/test/bundle.test.mjs @@ -0,0 +1,109 @@ +/** + * Smoke tests for the built `dist/sentry-mcp.cjs`. Run `pnpm run bundle` first. + */ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, test } from "node:test"; + +const packageRoot = new URL("..", import.meta.url).pathname; +const bin = join(packageRoot, "dist/sentry-mcp.cjs"); +const manifest = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf-8"), +); +const { version } = manifest; + +const configDir = mkdtempSync(join(tmpdir(), "sentry-mcp-test-")); +after(() => rmSync(configDir, { recursive: true, force: true })); + +const env = { + PATH: process.env.PATH, + SENTRY_CONFIG_DIR: configDir, + SENTRY_CLI_NO_TELEMETRY: "1", +}; + +function run(args, extraEnv = {}) { + return spawnSync(process.execPath, [bin, ...args], { + encoding: "utf-8", + env: { ...env, ...extraEnv }, + timeout: 30_000, + }); +} + +test("the package has no runtime dependencies", () => { + assert.deepEqual(manifest.dependencies ?? {}, {}); + assert.deepEqual(manifest.files, ["dist/sentry-mcp.cjs"]); +}); + +test("--version prints the package version", () => { + const result = run(["--version"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), `sentry-mcp ${version}`); +}); + +test("--help names the standalone command", () => { + const result = run(["--help"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^Usage: sentry-mcp /); + assert.match(result.stdout, /`sentry-mcp auth login`/); +}); + +test("auth subcommands other than login point to the CLI", () => { + const result = run(["auth", "status"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /npx -y sentry auth status/); +}); + +test("serves MCP over stdio with a static token", async () => { + const child = spawn(process.execPath, [bin], { + env: { ...env, SENTRY_ACCESS_TOKEN: "sntryu_test" }, + stdio: ["pipe", "pipe", "pipe"], + }); + const byId = new Map(); + let buffer = ""; + const responses = new Promise((resolve, reject) => { + child.on("error", reject); + child.stdout.on("data", (chunk) => { + buffer += chunk; + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + for (const message of parts + .filter(Boolean) + .map((line) => JSON.parse(line))) { + if (message.id !== undefined) { + byId.set(message.id, message); + } + } + if (byId.has(1) && byId.has(2)) { + resolve([byId.get(1), byId.get(2)]); + } + }); + setTimeout(() => reject(new Error("no MCP response")), 20_000).unref(); + }); + + const send = (message) => + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); + send({ + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "bundle-test", version: "1.0.0" }, + }, + }); + send({ method: "notifications/initialized" }); + send({ id: 2, method: "tools/list" }); + + try { + const [initialize, toolsList] = await responses; + assert.equal(initialize.id, 1); + assert.ok(initialize.result?.serverInfo, JSON.stringify(initialize)); + assert.equal(toolsList.id, 2); + assert.ok(toolsList.result?.tools.length > 0, JSON.stringify(toolsList)); + } finally { + child.kill(); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0921d81b3..9e4ba23e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -852,11 +852,7 @@ importers: specifier: ^2.8.3 version: 2.8.3 - packages/mcp-server: - dependencies: - sentry: - specifier: workspace:>=0.48.0-dev.0 - version: link:../cli + packages/mcp-server: {} packages/mcp-server-evals: dependencies: From 1dba7c61e4948e86a6789928bb31ec5fcd425ff6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:28:19 +0000 Subject: [PATCH 07/29] fix(mcp): Tear down after --help, --version, and sentry-mcp auth These invocations finish like any other drained command but skipped the force-exit backstop and dispatcher close that runCli applies, so leftover handles could keep the process alive on macOS. startMcpServer now reports whether a server is running, and runMcpServer and the standalone auth entry release resources whenever the invocation is finite. --- packages/cli/src/lib/mcp.ts | 14 +++++++---- packages/cli/src/lib/mcp/run.ts | 32 ++++++++++++++++--------- packages/cli/src/mcp-server.ts | 8 ++++++- packages/cli/test/lib/mcp-run.test.ts | 14 ++++++++++- packages/cli/test/lib/mcp-start.test.ts | 4 ++-- 5 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index 579a77dc2..cea64f9c3 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -147,8 +147,13 @@ export function resolveStaticMcpAccessToken( return token; } -/** Start the local stdio server without introducing a second auth flow. */ -export async function startMcpServer(args: string[]): Promise { +/** + * Start the local stdio server without introducing a second auth flow. + * + * Resolves `true` once the server is running, or `false` when the invocation + * only printed output (`--help`, `--version`) and the process can exit. + */ +export async function startMcpServer(args: string[]): Promise { if (args[0] === "auth") { throw new ValidationError( "Use `sentry auth` to manage credentials for `sentry mcp`.", @@ -160,7 +165,7 @@ export async function startMcpServer(args: string[]): Promise { const invocation = parseMcpInvocation(prepareMcpServerArgs(args), env); if (invocation.kind === "output") { process.stdout.write(`${invocation.text}\n`); - return; + return false; } const { config } = invocation; @@ -170,7 +175,7 @@ export async function startMcpServer(args: string[]): Promise { accessToken: resolveStaticMcpAccessToken({ ...config, accessToken }), env, }); - return; + return true; } serveMcpStdio(config, { @@ -180,4 +185,5 @@ export async function startMcpServer(args: string[]): Promise { refreshAccessToken: () => resolveCliMcpAccessToken(config, { force: true }), env, }); + return true; } diff --git a/packages/cli/src/lib/mcp/run.ts b/packages/cli/src/lib/mcp/run.ts index 7adadbfd6..88432d705 100644 --- a/packages/cli/src/lib/mcp/run.ts +++ b/packages/cli/src/lib/mcp/run.ts @@ -7,21 +7,31 @@ import { formatError, getExitCode } from "../errors.js"; import { startMcpServer } from "../mcp.js"; +/** + * Release network resources after a finite MCP invocation, mirroring the + * teardown `runCli` applies to every other drained command. Never call this + * while a stdio server is running: it would close the server's dispatcher and + * arm a force-exit on macOS. + */ +export async function releaseProcessResources(): Promise { + const [{ scheduleForceExit }, { closeGlobalDispatcher }] = await Promise.all([ + import("../force-exit.js"), + import("../close-dispatcher.js"), + ]); + scheduleForceExit(); + await closeGlobalDispatcher(); +} + export async function runMcpServer(args: string[]): Promise { try { - await startMcpServer(args); + if (await startMcpServer(args)) { + return; + } } catch (mcpError) { process.stderr.write(`Error: ${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("../force-exit.js"), - import("../close-dispatcher.js"), - ]); - scheduleForceExit(); - await closeGlobalDispatcher(); } + // Setup errors and `--help`/`--version` are terminal, unlike a running + // stdio server. + await releaseProcessResources(); } diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts index 4f9f48c7d..f2d34706a 100644 --- a/packages/cli/src/mcp-server.ts +++ b/packages/cli/src/mcp-server.ts @@ -25,12 +25,18 @@ async function main(args: string[]): Promise { } if (args[0] === "auth") { - const { runMcpServerAuth } = await import("./lib/mcp/auth.js"); + const [{ runMcpServerAuth }, { releaseProcessResources }] = + await Promise.all([ + import("./lib/mcp/auth.js"), + import("./lib/mcp/run.js"), + ]); try { await runMcpServerAuth(args.slice(1)); } catch (err) { process.stderr.write(`Error: ${formatError(err)}\n`); process.exitCode = getExitCode(err); + } finally { + await releaseProcessResources(); } return; } diff --git a/packages/cli/test/lib/mcp-run.test.ts b/packages/cli/test/lib/mcp-run.test.ts index aefe31ff8..77e48ba18 100644 --- a/packages/cli/test/lib/mcp-run.test.ts +++ b/packages/cli/test/lib/mcp-run.test.ts @@ -36,7 +36,7 @@ describe("runMcpCommand", () => { }); test("keeps a running server alive on success", async () => { - startMcpServer.mockResolvedValue(undefined); + startMcpServer.mockResolvedValue(true); const { runMcpCommand } = await import("../../src/cli.js"); await expect(runMcpCommand(["mcp"])).resolves.toBe(true); @@ -48,6 +48,18 @@ describe("runMcpCommand", () => { expect(closeGlobalDispatcher).not.toHaveBeenCalled(); }); + test("cleans up after an invocation that only prints output", async () => { + startMcpServer.mockResolvedValue(false); + const { runMcpCommand } = await import("../../src/cli.js"); + + await expect(runMcpCommand(["mcp", "--help"])).resolves.toBe(true); + + // `--help` and `--version` finish like any other drained command, so they + // need the same teardown `runCli` gives every other command. + expect(scheduleForceExit).toHaveBeenCalledTimes(1); + expect(closeGlobalDispatcher).toHaveBeenCalledTimes(1); + }); + test("cleans up network resources when setup fails", async () => { startMcpServer.mockRejectedValue(new Error("boom")); const { runMcpCommand } = await import("../../src/cli.js"); diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 95607f6ba..f8bf19a55 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -46,7 +46,7 @@ afterEach(async () => { async function start(args: string[]): Promise { const { startMcpServer } = await import("../../src/lib/mcp.js"); - await startMcpServer(args); + await expect(startMcpServer(args)).resolves.toBe(true); expect(served.calls).toHaveLength(1); const [call] = served.calls; if (!call) { @@ -185,7 +185,7 @@ describe("startMcpServer output flags", () => { .mockImplementation(() => true); const { startMcpServer } = await import("../../src/lib/mcp.js"); - await startMcpServer([flag]); + await expect(startMcpServer([flag])).resolves.toBe(false); expect(served.calls).toHaveLength(0); expect(write).toHaveBeenCalledWith( From 26387b254db767e9c1c8b06d3df10a2abc2d6d82 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:55:24 +0000 Subject: [PATCH 08/29] docs(mcp): Keep @sentry/mcp-server as the recommended stdio install Restore the web install instructions, the MCP registry manifest, and its publish workflow to @sentry/mcp-server, and move mcpName back to that package. Docs and READMEs recommend npx @sentry/mcp-server@latest and mention sentry mcp as the same server. Co-Authored-By: BYK --- .github/workflows/mcp-registry.yml | 18 ++++------ AGENTS.md | 2 +- README.md | 19 +++++----- .../integrations/ide-instructions-refactor.md | 2 +- docs/operations/embedded-agents.md | 18 +++++----- docs/releases/stdio.md | 36 +++++++++++-------- docs/specs/embedded-agent-openai-routing.md | 8 ++--- docs/testing/stdio.md | 26 ++++++-------- packages/cli/package.json | 1 - .../components/fragments/instructions/amp.tsx | 2 +- .../fragments/instructions/claude-code.tsx | 2 +- .../fragments/instructions/codex-cli.tsx | 2 +- .../fragments/instructions/cursor.tsx | 2 +- .../components/fragments/instructions/fx.tsx | 4 +-- .../fragments/instructions/gemini.tsx | 2 +- .../fragments/instructions/opencode.tsx | 2 +- .../components/fragments/instructions/pi.tsx | 2 +- .../fragments/instructions/vscode.tsx | 4 +-- .../fragments/instructions/warp.tsx | 2 +- .../components/fragments/instructions/zed.tsx | 2 +- .../components/fragments/stdio-setup.tsx | 19 +++++----- packages/mcp-cloudflare/src/constants.ts | 2 +- packages/mcp-core/README.md | 22 ++++++------ packages/mcp-server/package.json | 1 + server.json | 15 ++++---- 25 files changed, 105 insertions(+), 110 deletions(-) diff --git a/.github/workflows/mcp-registry.yml b/.github/workflows/mcp-registry.yml index 22289bc66..4cff8e1c3 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 CLI release (e.g. 0.48.0) + description: Existing stable release (e.g. 0.40.0) required: true type: string @@ -24,8 +24,8 @@ concurrency: jobs: publish: if: >- - github.repository == 'getsentry/toolkit' && - ((github.event_name == 'release' && !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'cli@')) || + (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 == 'workflow_dispatch' && github.ref == 'refs/heads/main')) runs-on: ubuntu-latest timeout-minutes: 15 @@ -33,7 +33,7 @@ jobs: contents: read id-token: write env: - RELEASE: ${{ inputs.version || github.event.release.tag_name }} + VERSION: ${{ inputs.version || github.event.release.tag_name }} steps: # Use the maintained manifest on main, including for older releases. - uses: actions/checkout@v4 @@ -45,12 +45,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - # The registry entry points at the `sentry` npm package (`sentry mcp`), - # which is released from the CLI workspace with `cli@` tags. - VERSION="${RELEASE#cli@}" [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - gh release view "cli@$VERSION" --repo getsentry/toolkit --json isDraft,isPrerelease \ + gh release view "$VERSION" --repo getsentry/sentry-mcp --json isDraft,isPrerelease \ | jq -e '(.isDraft | not) and (.isPrerelease | not)' jq --arg version "$VERSION" '.version = $version | .packages[0].version = $version' \ server.json > "$RUNNER_TEMP/server.json" @@ -58,10 +54,10 @@ jobs: # Craft publishes the GitHub release before npm. Wait for the exact package. curl --fail --silent --show-error --retry 40 --retry-all-errors \ --retry-delay 15 --retry-max-time 600 --max-time 15 \ - "https://registry.npmjs.org/sentry/$VERSION" \ + "https://registry.npmjs.org/@sentry%2Fmcp-server/$VERSION" \ -o "$RUNNER_TEMP/npm.json" jq -e --arg version "$VERSION" --slurpfile manifest "$RUNNER_TEMP/server.json" \ - '.name == "sentry" and .version == $version and .mcpName == $manifest[0].name' \ + '.name == "@sentry/mcp-server" and .version == $version and .mcpName == $manifest[0].name' \ "$RUNNER_TEMP/npm.json" - name: Check for an existing listing diff --git a/AGENTS.md b/AGENTS.md index a99c69021..ceb410cc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ toolkit/ **MCP releases:** -- docs/releases/stdio.md — `sentry mcp` release, registry, and the `@sentry/mcp-server` bundle +- docs/releases/stdio.md — `@sentry/mcp-server` and `sentry mcp` releases, and the MCP registry - docs/releases/cloudflare.md — Cloudflare deployment ## Commands diff --git a/README.md b/README.md index 1463a95e6..10ad20ca0 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ 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. -The `stdio` transport is the `sentry mcp` command in the [`sentry` CLI](https://cli.sentry.dev). It uses your CLI login (`sentry auth login`), or a static token from `--access-token` / `SENTRY_ACCESS_TOKEN`. The same server is also published on its own as `@sentry/mcp-server` (`npx -y @sentry/mcp-server`): one bundled file without the rest of the CLI, with the same flags and self-hosted support. +The `stdio` transport ships as [`@sentry/mcp-server`](https://www.npmjs.com/package/@sentry/mcp-server): the `sentry mcp` server from the [`sentry` CLI](https://cli.sentry.dev), bundled into one file without the rest of the CLI. It uses your Sentry CLI login (`npx @sentry/mcp-server@latest auth login`, or `sentry auth login` if you have the CLI), or a static token from `--access-token` / `SENTRY_ACCESS_TOKEN`. `npx sentry@latest mcp` runs the same server with the same flags. To use a static token, create a User Auth Token in Sentry with the necessary scopes. As of writing this is: @@ -61,8 +61,9 @@ event:write Launch the transport: ```shell -npx -y sentry@latest mcp # uses `sentry auth login` -npx -y sentry@latest mcp --access-token=sentry-user-token +npx @sentry/mcp-server@latest auth login # once, to sign in +npx @sentry/mcp-server@latest # uses that login +npx @sentry/mcp-server@latest --access-token=sentry-user-token ``` Need to connect to a self-hosted deployment? Add --host (hostname @@ -75,20 +76,20 @@ default skill set whenever `--host` points at a non-`sentry.io` host. If your self-hosted deployment does run Seer, opt back in explicitly: ```shell -npx -y sentry@latest mcp --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer +npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer ``` You can also disable any other skill to prevent unsupported tools from being exposed: ```shell -npx -y sentry@latest mcp --access-token=TOKEN --host=sentry.example.com --disable-skills=project-management +npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com --disable-skills=project-management ``` For self-hosted instances without TLS: ```shell -npx -y sentry@latest mcp --access-token=TOKEN --host=sentry.internal:9000 --insecure-http +npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.internal:9000 --insecure-http ``` #### Remote with an Explicit Sentry Token @@ -121,7 +122,7 @@ tools with `?skills=inspect,triage` or `?disable-skills=seer`. #### Environment Variables ```shell -SENTRY_ACCESS_TOKEN= # Optional with `sentry auth login`: static Sentry auth token +SENTRY_ACCESS_TOKEN= # Optional after `auth login`: static Sentry auth token # LLM Provider Configuration (required for AI-powered search tools) EMBEDDED_AGENT_PROVIDER= # Required when multiple provider keys are set: 'openai', 'azure-openai', 'anthropic', or 'openrouter' @@ -146,7 +147,7 @@ MCP_DISABLE_SKILLS= # Disable specific skills (comma-separated, e.g. 'p "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "sentry@latest", "mcp"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -169,7 +170,7 @@ self-hosted deployment that does run Seer, opt in with `MCP_SKILLS`: "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "sentry@latest", "mcp"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "SENTRY_HOST": "sentry.example.com", diff --git a/docs/integrations/ide-instructions-refactor.md b/docs/integrations/ide-instructions-refactor.md index 9e6174787..2bab2d2e2 100644 --- a/docs/integrations/ide-instructions-refactor.md +++ b/docs/integrations/ide-instructions-refactor.md @@ -181,7 +181,7 @@ export default function InstallTabs({ ```typescript
        -
      1. Run npx -y sentry@latest mcp
      2. +
      3. Run npx @sentry/mcp-server
      4. {/* ... inline instructions */}
      diff --git a/docs/operations/embedded-agents.md b/docs/operations/embedded-agents.md index 2f1b58a2b..acdc97fa3 100644 --- a/docs/operations/embedded-agents.md +++ b/docs/operations/embedded-agents.md @@ -59,7 +59,7 @@ Or: ```bash export EMBEDDED_AGENT_PROVIDER=azure-openai export OPENAI_API_KEY=sk-... -npx -y sentry@latest mcp --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 -y sentry@latest mcp --agent-provider=openai --access-token=... +npx @sentry/mcp-server@latest --agent-provider=openai --access-token=... ``` #### Method 3: MCP Configuration File @@ -95,7 +95,7 @@ In your MCP settings (e.g., Claude Desktop config): "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "sentry@latest", "mcp"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -137,7 +137,7 @@ Always set `EMBEDDED_AGENT_PROVIDER` when multiple API keys might be present: "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "sentry@latest", "mcp"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "your-token", "EMBEDDED_AGENT_PROVIDER": "openai", @@ -194,7 +194,7 @@ For custom OpenAI-compatible endpoints or Anthropic proxies: ```bash export EMBEDDED_AGENT_PROVIDER=openai export OPENAI_API_KEY=sk-... -npx -y sentry@latest mcp --openai-base-url=https://custom.openai.example.com +npx @sentry/mcp-server@latest --openai-base-url=https://custom.openai.example.com ``` For Azure OpenAI or Azure-compatible deployment proxies, use the dedicated @@ -206,10 +206,10 @@ export OPENAI_API_KEY=sk-... export OPENAI_API_VERSION=2024-02-15-preview # Azure v1 endpoint -> Responses API -npx -y sentry@latest mcp --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 -y sentry@latest mcp --openai-base-url=https://example.openai.azure.com/openai/deployments/my-assistant +npx @sentry/mcp-server@latest --openai-base-url=https://example.openai.azure.com/openai/deployments/my-assistant ``` ### Model Selection @@ -233,7 +233,7 @@ export OPENROUTER_REASONING_EFFORT=high Check which provider is being used: ```bash -npx -y sentry@latest mcp --access-token=... 2>&1 | grep "Using" +npx @sentry/mcp-server@latest --access-token=... 2>&1 | grep "Using" ``` Expected output: @@ -290,7 +290,7 @@ export ANTHROPIC_API_KEY=sk-ant-test export OPENAI_API_KEY=sk-test export EMBEDDED_AGENT_PROVIDER=openai -npx -y sentry@latest mcp --access-token=... +npx @sentry/mcp-server@latest --access-token=... ``` Should output: diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index 29fa28eae..98374b87e 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -4,10 +4,13 @@ How the local stdio MCP server ships. ## Overview -The stdio MCP server is the `sentry mcp` command in the `sentry` npm package -(`packages/cli`). It ships with every CLI release (the `packages/cli` Craft -workspace in `.craft.yml`). The same server also ships on its own as -`@sentry/mcp-server` (see below). +The stdio MCP server is implemented in the CLI (`packages/cli`) and ships two +ways: + +- `@sentry/mcp-server`, the recommended install: the server bundled into one + file (see below), released from the `packages/mcp-server` Craft workspace. +- `sentry mcp`, the same server as a CLI command, released with every CLI + version (the `packages/cli` Craft workspace). `sentry mcp` bundles `@sentry/mcp-core` (tools, server, and the stdio transport) and uses the CLI's own authentication: @@ -38,24 +41,25 @@ runtime dependencies. `packages/cli/script/bundle-mcp-server.ts` bundles `pnpm --filter @sentry/mcp-server run bundle` builds it and `run test:bundle` smoke-tests the result. The `packages/mcp-server` Craft workspace releases it from `release/mcp` branches; `.github/workflows/merge-jobs.yml` bundles, tests, -and packs it. +and packs it. Each release bundles the CLI source at that commit, so it does +not wait for a CLI release. ## MCP Registry -The **Publish MCP Registry** workflow publishes `server.json` on stable CLI -releases (`cli@X.Y.Z` tags) using GitHub Actions OIDC; no registry secret is -needed. It reads the manifest from `main` and sets both versions from the -release, so there is no extra version bump to maintain. The listing points at -the `sentry` npm package with `mcp` as a package argument, plus the hosted +The **Publish MCP Registry** workflow publishes `server.json` on stable +`@sentry/mcp-server` releases (not `cli@` tags) using GitHub Actions OIDC; no +registry secret is needed. It reads the manifest from `main` and sets both +versions from the release, so there is no extra version bump to maintain. The +listing points at the `@sentry/mcp-server` npm package, plus the hosted endpoint. npm verifies ownership through `mcpName` in -`packages/cli/package.json`. +`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: ```bash -gh workflow run mcp-registry.yml --repo getsentry/toolkit --ref main -f version=0.48.0 +gh workflow run mcp-registry.yml --repo getsentry/toolkit --ref main -f version=0.42.0 ``` An identical active listing is safe to rerun. Different metadata for an existing @@ -71,14 +75,16 @@ Users run the server through npx in their MCP client configuration: "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "sentry@latest", "mcp"] + "args": ["@sentry/mcp-server@latest"] } } } ``` -Without a `sentry auth login` session, add `"env": { "SENTRY_ACCESS_TOKEN": -"sntryu_..." }`. Pin a version with `sentry@0.48.0`. +Sign in once with `npx @sentry/mcp-server@latest auth login` (or +`sentry auth login`), or add `"env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." }`. +Pin a version with `@sentry/mcp-server@0.42.0`. `npx sentry@latest mcp` runs the +same server. ## Testing Before a Release diff --git a/docs/specs/embedded-agent-openai-routing.md b/docs/specs/embedded-agent-openai-routing.md index ab32ce1cc..0c66639c8 100644 --- a/docs/specs/embedded-agent-openai-routing.md +++ b/docs/specs/embedded-agent-openai-routing.md @@ -155,7 +155,7 @@ The key requirement is conceptual separation, not the specific SDK choice. ### Direct OpenAI ```bash -npx -y sentry@latest mcp --access-token=TOKEN +npx @sentry/mcp-server@latest --access-token=TOKEN ``` Result: auto-detects `openai` when `OPENAI_API_KEY` is present and uses @@ -164,7 +164,7 @@ Responses API. ### Generic OpenAI-Compatible Proxy ```bash -npx -y sentry@latest mcp \ +npx @sentry/mcp-server@latest \ --access-token=TOKEN \ --agent-provider=openai \ --openai-base-url=https://proxy.example.com/v1 \ @@ -177,7 +177,7 @@ deployment alias. ### Azure-Compatible Deployment Proxy ```bash -npx -y sentry@latest mcp \ +npx @sentry/mcp-server@latest \ --access-token=TOKEN \ --agent-provider=azure-openai \ --openai-base-url=https://proxy.example.com/openai/deployments/my-assistant \ @@ -192,7 +192,7 @@ alias. ```bash export OPENAI_API_VERSION=2024-02-15-preview -npx -y sentry@latest mcp \ +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/stdio.md b/docs/testing/stdio.md index 8f938cebd..5cba855b2 100644 --- a/docs/testing/stdio.md +++ b/docs/testing/stdio.md @@ -4,7 +4,7 @@ Complete playbook for building, running, and testing the stdio MCP server. ## Overview -The stdio transport runs the MCP server as a subprocess that communicates via stdin/stdout pipes. This is the standard way IDEs and local tools integrate with MCP servers. The stdio server is the `sentry mcp` command in the Sentry CLI (`packages/cli`); it bundles `@sentry/mcp-core`. +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:** @@ -64,13 +64,13 @@ pnpm --silent --dir packages/cli run cli mcp --access-token=YOUR_TOKEN ### 3. Run the Published Package ```bash -npx -y sentry@latest mcp -npx -y sentry@latest mcp --access-token=YOUR_TOKEN +npx @sentry/mcp-server@latest +npx @sentry/mcp-server@latest --access-token=YOUR_TOKEN ``` -To test a local npm bundle, build it with `pnpm --filter sentry run bundle` -(unset `SENTRY_AUTH_TOKEN` first so the build does not upload sourcemaps), then -run `node packages/cli/dist/bin.cjs mcp`. +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 @@ -116,9 +116,7 @@ This opens the MCP Inspector at `http://localhost:6274` { "command": "npx", "args": [ - "-y", - "sentry@latest", - "mcp", + "@sentry/mcp-server@latest", "--access-token=YOUR_TOKEN", "--host=sentry.example.com" ] @@ -131,9 +129,7 @@ This opens the MCP Inspector at `http://localhost:6274` { "command": "npx", "args": [ - "-y", - "sentry@latest", - "mcp", + "@sentry/mcp-server@latest", "--access-token=YOUR_TOKEN", "--host=sentry.internal:9000", "--insecure-http" @@ -278,7 +274,7 @@ Create or edit `.cursor/config.json`: "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "sentry@latest", "mcp", "--access-token=YOUR_TOKEN"] + "args": ["@sentry/mcp-server@latest", "--access-token=YOUR_TOKEN"] } } } @@ -299,7 +295,7 @@ Update config: "mcpServers": { "sentry": { "command": "npx", - "args": ["-y", "sentry@latest", "mcp"], + "args": ["@sentry/mcp-server@latest"], "env": { "SENTRY_ACCESS_TOKEN": "${SENTRY_ACCESS_TOKEN}" } @@ -331,7 +327,7 @@ Add to `.vscode/settings.json`: "mcp.servers": { "sentry": { "command": "npx", - "args": ["-y", "sentry@latest", "mcp", "--access-token=YOUR_TOKEN"] + "args": ["@sentry/mcp-server@latest", "--access-token=YOUR_TOKEN"] } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 619606f78..15bbc3c18 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,6 @@ "name": "sentry", "version": "0.48.0-dev.0", "description": "Sentry CLI - A command-line interface for using Sentry built by robots and humans for robots and humans", - "mcpName": "io.github.getsentry/sentry-mcp", "repository": { "type": "git", "url": "git+https://github.com/getsentry/toolkit.git" diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/amp.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/amp.tsx index 5768ca44e..a18e116b6 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/amp.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/amp.tsx @@ -47,7 +47,7 @@ export function AmpInstructions({ transport }: AmpInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["-y", "sentry@latest", "mcp"], + args: ["@sentry/mcp-server@latest"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/claude-code.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/claude-code.tsx index aaf09157f..1308d1880 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/claude-code.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/claude-code.tsx @@ -44,7 +44,7 @@ export function ClaudeCodeInstructions({ } // Stdio transport - const mcpStdioSnippet = `npx -y ${NPM_PACKAGE_NAME}@latest mcp`; + const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`; return ( <>
        diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/codex-cli.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/codex-cli.tsx index d7e8a0a30..7dd8fe41c 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/codex-cli.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/codex-cli.tsx @@ -52,7 +52,7 @@ export function CodexCLIInstructions({ transport }: CodexCLIInstructionsProps) { const codexConfigToml = [ "[mcp_servers.sentry]", 'command = "npx"', - 'args = ["-y", "sentry@latest", "mcp"]', + 'args = ["@sentry/mcp-server@latest"]', 'env = { SENTRY_ACCESS_TOKEN = "sentry-user-token", OPENAI_API_KEY = "your-openai-key" }', ].join("\n"); const selfHostedEnvLine = diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/cursor.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/cursor.tsx index ca482f60d..fa6b888c0 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/cursor.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/cursor.tsx @@ -71,7 +71,7 @@ export function CursorInstructions({ transport }: CursorInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["-y", "sentry@latest", "mcp"], + args: ["@sentry/mcp-server@latest"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx index 81e413149..b6b746eb6 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/fx.tsx @@ -42,7 +42,7 @@ export function FxInstructions({ transport }: FxInstructionsProps) { } // Stdio transport - const mcpStdioSnippet = `npx -y ${NPM_PACKAGE_NAME}@latest mcp`; + const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`; return ( <>
          @@ -65,7 +65,7 @@ export function FxInstructions({ transport }: FxInstructionsProps) { mcp: { sentry: { type: "stdio", - command: ["npx", "-y", `${NPM_PACKAGE_NAME}@latest`, "mcp"], + command: ["npx", `${NPM_PACKAGE_NAME}@latest`], environment: { SENTRY_ACCESS_TOKEN: "sentry-user-token", OPENAI_API_KEY: "your-openai-key", diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/gemini.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/gemini.tsx index a4816b8e3..778e23ec1 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/gemini.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/gemini.tsx @@ -64,7 +64,7 @@ export function GeminiInstructions({ transport }: GeminiInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["-y", "sentry@latest", "mcp"], + args: ["@sentry/mcp-server@latest"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/opencode.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/opencode.tsx index c7e44dc00..c77e118ae 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/opencode.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/opencode.tsx @@ -65,7 +65,7 @@ export function OpenCodeInstructions({ transport }: OpenCodeInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["-y", "sentry@latest", "mcp"], + args: ["@sentry/mcp-server@latest"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/pi.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/pi.tsx index edce12447..f3e45ca4f 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/pi.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/pi.tsx @@ -54,7 +54,7 @@ export function PiInstructions({ transport }: PiInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["-y", "sentry@latest", "mcp"], + args: ["@sentry/mcp-server@latest"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/vscode.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/vscode.tsx index ced71c9b7..84edfea31 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/vscode.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/vscode.tsx @@ -66,14 +66,14 @@ export function VSCodeInstructions({ transport }: VSCodeInstructionsProps) { } // Stdio transport - const mcpStdioSnippet = `npx -y ${NPM_PACKAGE_NAME}@latest mcp`; + const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`; const defaultEnv = { SENTRY_ACCESS_TOKEN: "sentry-user-token", OPENAI_API_KEY: "your-openai-key", } as const; const coreConfig = { command: "npx", - args: ["-y", "sentry@latest", "mcp"], + args: ["@sentry/mcp-server@latest"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/warp.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/warp.tsx index 69b6ca7f1..c557c3fbf 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/warp.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/warp.tsx @@ -83,7 +83,7 @@ export function WarpInstructions({ transport }: WarpInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["-y", "sentry@latest", "mcp"], + args: ["@sentry/mcp-server@latest"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/instructions/zed.tsx b/packages/mcp-cloudflare/src/client/components/fragments/instructions/zed.tsx index f07957c7c..e67f9cca5 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/instructions/zed.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/instructions/zed.tsx @@ -47,7 +47,7 @@ export function ZedInstructions({ transport }: ZedInstructionsProps) { } as const; const coreConfig = { command: "npx", - args: ["-y", "sentry@latest", "mcp"], + args: ["@sentry/mcp-server@latest"], env: defaultEnv, }; diff --git a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx index a7f03ebb1..6a4f0e20d 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx @@ -11,7 +11,7 @@ const orderedSkills = [...skillDefinitions] .sort((a, b) => a.order - b.order); export default function StdioSetup() { - const mcpStdioSnippet = `npx -y ${NPM_PACKAGE_NAME}@latest mcp`; + const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`; const selfHostedHostExample = [ `${mcpStdioSnippet}`, @@ -23,12 +23,11 @@ export default function StdioSetup() { <>

          - The stdio server is the sentry mcp command in the{" "} + The stdio client is made available on npm at{" "} {NPM_PACKAGE_NAME} - {" "} - CLI package. If you have run sentry auth login, it uses - that session and you can skip the token below. + + .

          Note: The MCP is developed against the cloud service @@ -93,10 +92,7 @@ export default function StdioSetup() {

          --access-token / SENTRY_ACCESS_TOKEN
          -
          - User auth token. Optional when you have run{" "} - sentry auth login. -
          +
          Required user auth token.
          --host / SENTRY_HOST @@ -177,8 +173,9 @@ export default function StdioSetup() {

          - Need something else? Run npx -y sentry@latest mcp --help{" "} - to view the full flag list. + Need something else? Run{" "} + npx @sentry/mcp-server@latest --help to view the full + flag list.

          diff --git a/packages/mcp-cloudflare/src/constants.ts b/packages/mcp-cloudflare/src/constants.ts index f8895c715..69c8d7958 100644 --- a/packages/mcp-cloudflare/src/constants.ts +++ b/packages/mcp-cloudflare/src/constants.ts @@ -1,3 +1,3 @@ export { SCOPES } from "@sentry/mcp-core/scopes"; -export const NPM_PACKAGE_NAME = "sentry"; +export const NPM_PACKAGE_NAME = "@sentry/mcp-server"; diff --git a/packages/mcp-core/README.md b/packages/mcp-core/README.md index 60da4828d..695422d06 100644 --- a/packages/mcp-core/README.md +++ b/packages/mcp-core/README.md @@ -2,7 +2,7 @@ This is a prototype of an MCP server, acting as a middleware to the upstream Sentry API provider. -This package holds the MCP tools and server shared by the hosted service and the local `stdio` server, which ships 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: +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 -y sentry@latest mcp --access-token=sentry-user-token +npx @sentry/mcp-server@latest --access-token=sentry-user-token # Limit to specific skills only -npx -y sentry@latest mcp --access-token=TOKEN --skills=inspect,docs +npx @sentry/mcp-server@latest --access-token=TOKEN --skills=inspect,docs # Grant all active, non-deprecated skills, overriding MCP_SKILLS -npx -y sentry@latest mcp --access-token=TOKEN --all-skills +npx @sentry/mcp-server@latest --access-token=TOKEN --all-skills # Self-hosted Sentry -npx -y sentry@latest mcp --access-token=TOKEN --host=sentry.example.com +npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com # Override OpenAI endpoint for AI-powered tools (stdio only) -npx -y sentry@latest mcp --access-token=TOKEN --openai-base-url=https://proxy.example.com/v1 +npx @sentry/mcp-server@latest --access-token=TOKEN --openai-base-url=https://proxy.example.com/v1 # Azure OpenAI / Azure-compatible deployment routing (stdio only) -npx -y sentry@latest mcp --access-token=TOKEN --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ +npx @sentry/mcp-server@latest --access-token=TOKEN --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 @@ -120,10 +120,10 @@ If you're currently using scopes: ```shell # OLD (deprecated) -npx -y sentry@latest mcp --access-token=TOKEN --scopes=org:read,event:write +npx @sentry/mcp-server@latest --access-token=TOKEN --scopes=org:read,event:write # NEW (recommended) -npx -y sentry@latest mcp --access-token=TOKEN --skills=inspect,triage +npx @sentry/mcp-server@latest --access-token=TOKEN --skills=inspect,triage ``` The host configuration accepts two distinct formats: @@ -137,8 +137,8 @@ By default we also enable Sentry reporting (traces, errors) upstream to our clou ```shell # disable sentry reporting -npx -y sentry@latest mcp --sentry-dsn= +npx @sentry/mcp-server@latest --sentry-dsn= # use custom sentry instance -npx -y sentry@latest mcp --sentry-dsn=https://publicKey@mysentry.example.com/... +npx @sentry/mcp-server@latest --sentry-dsn=https://publicKey@mysentry.example.com/... ``` diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index e2659d66b..bde7a3445 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,5 +1,6 @@ { "name": "@sentry/mcp-server", + "mcpName": "io.github.getsentry/sentry-mcp", "version": "0.42.0", "description": "Sentry MCP server over stdio: the CLI's `sentry mcp` server, bundled without the rest of the CLI", "sentryCi": { diff --git a/server.json b/server.json index 3c73d5ab4..ae9c1b41a 100644 --- a/server.json +++ b/server.json @@ -3,22 +3,21 @@ "name": "io.github.getsentry/sentry-mcp", "description": "MCP server for Sentry - error monitoring, issue tracking, and debugging for AI assistants", "repository": { - "url": "https://github.com/getsentry/toolkit", + "url": "https://github.com/getsentry/sentry-mcp", "source": "github", - "subfolder": "packages/cli" + "subfolder": "packages/mcp-server" }, - "version": "0.48.0", + "version": "0.40.0", "packages": [ { "registryType": "npm", - "identifier": "sentry", - "version": "0.48.0", + "identifier": "@sentry/mcp-server", + "version": "0.40.0", "transport": { "type": "stdio" }, - "packageArguments": [{ "type": "positional", "value": "mcp" }], "environmentVariables": [ { - "description": "Sentry user auth token. Optional when you have run `sentry auth login`.", - "isRequired": false, + "description": "Your Sentry user authentication token", + "isRequired": true, "format": "string", "isSecret": true, "name": "SENTRY_ACCESS_TOKEN" From 47afbd01721e2e8664e5a6996403bccbeb27a9b6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:02:07 +0000 Subject: [PATCH 09/29] fix(mcp-server): Point auth hints at the package's own auth login Auth errors in the standalone bundle told users to run 'sentry auth login', which needs the full CLI. A shared login-command constant now names 'npx -y @sentry/mcp-server auth login' when running as the bundle and 'sentry auth login' otherwise. Hints that confirm a self-hosted host with --url still name the CLI, because the standalone auth login takes no flags. Co-Authored-By: BYK --- docs/operations/stdio-auth.md | 13 ++++++--- packages/cli/src/lib/custom-headers.ts | 3 +- packages/cli/src/lib/db/auth.ts | 3 +- packages/cli/src/lib/db/migration.ts | 3 +- packages/cli/src/lib/errors.ts | 12 ++++---- packages/cli/src/lib/login-command.ts | 28 +++++++++++++++++++ packages/cli/src/lib/login-host-guard.ts | 3 +- packages/cli/src/lib/mcp.ts | 4 +-- packages/cli/src/lib/mcp/usage.ts | 16 ++--------- packages/cli/src/lib/oauth.ts | 5 ++-- .../cli/test/lib/mcp/usage-standalone.test.ts | 12 +++++++- packages/mcp-server/test/bundle.test.mjs | 9 +++++- 12 files changed, 78 insertions(+), 33 deletions(-) create mode 100644 packages/cli/src/lib/login-command.ts diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index bfdc9602e..0c1b3b687 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -26,8 +26,12 @@ In order of precedence: When no credential is available and stdin is a TTY, `sentry mcp` starts the CLI's interactive login before serving. MCP clients launch the server over -pipes, so in that case it exits with an error that tells the user to run -`sentry auth login`. +pipes, so in that case it exits with an error that names the login command: +`npx -y @sentry/mcp-server auth login` from `@sentry/mcp-server`, and +`sentry auth login` from `sentry mcp`. Other auth hints follow the same rule +(`packages/cli/src/lib/login-command.ts`), except that confirming a self-hosted host with +`--url` always names `npx -y sentry auth login --url`, because the +standalone `auth login` takes no flags. For self-hosted Sentry, set the host with `--host`/`--url` (or the CLI's `SENTRY_HOST`/`SENTRY_URL`). Pair `--host` with `--insecure-http` for instances @@ -37,8 +41,9 @@ that only expose plain HTTP. The standalone package's device code flow, bundled client ID, and token cache (`~/.sentry/mcp.json`, `SENTRY_MCP_AUTH_CACHE`) are gone. `sentry mcp` does not -read the old cache. Users who relied on it run `sentry auth login` once, or keep -passing `--access-token` / `SENTRY_ACCESS_TOKEN`. +read the old cache. Users who relied on it run `npx -y @sentry/mcp-server auth login` (or +`sentry auth login`) once, or keep passing `--access-token` / +`SENTRY_ACCESS_TOKEN`. `@sentry/mcp-server` now bundles this same server and shares the CLI's credential store. `sentry-mcp auth login` runs the CLI's device flow; use diff --git a/packages/cli/src/lib/custom-headers.ts b/packages/cli/src/lib/custom-headers.ts index 67a7f592f..1d73316c5 100644 --- a/packages/cli/src/lib/custom-headers.ts +++ b/packages/cli/src/lib/custom-headers.ts @@ -36,6 +36,7 @@ import { getActiveTokenHost, isRequestOriginTrustedForCustomHeaders, } from "./token-host.js"; +import { URL_LOGIN_COMMAND } from "./login-command.js"; const log = logger.withTag("custom-headers"); @@ -352,7 +353,7 @@ export function applyCustomHeaders( untrustedDestinationWarningLogged = true; log.warn( "Skipping custom headers for request to untrusted host. " + - "If this is legitimate, run 'sentry auth login --url ' against the intended instance.", + `If this is legitimate, run '${URL_LOGIN_COMMAND} ' against the intended instance.`, ); } return; diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index a41d54c6a..290ac85a8 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -68,6 +68,7 @@ import { getDatabase } from "./index.js"; import { clearCompletionCache } from "./completion-cache.js"; import { clearAllIssueOrgCache } from "./issue-org-cache.js"; import { clearTrustedHostState } from "./regions.js"; +import { LOGIN_COMMAND } from "../login-command.js"; /** Refresh when less than 10% of token lifetime remains */ export const REFRESH_THRESHOLD = 0.1; @@ -1728,7 +1729,7 @@ export async function refreshToken( } throw new AuthError( "expired", - "Session expired and no refresh token available. Run 'sentry auth login'.", + `Session expired and no refresh token available. Run '${LOGIN_COMMAND}'.`, ); } diff --git a/packages/cli/src/lib/db/migration.ts b/packages/cli/src/lib/db/migration.ts index d57778222..88a90e366 100644 --- a/packages/cli/src/lib/db/migration.ts +++ b/packages/cli/src/lib/db/migration.ts @@ -14,6 +14,7 @@ import { MalformedAuthTokenError } from "../errors.js"; import { logger } from "../logger.js"; import { getConfigDir } from "./index.js"; import type { Database } from "./sqlite.js"; +import { LOGIN_COMMAND } from "../login-command.js"; const log = logger.withTag("migration"); @@ -305,7 +306,7 @@ export function migrateFromJson(db: Database): void { if (invalidAuthToken) { log.warn( "Malformed authentication credentials were not migrated. The original config.json was kept. " + - "Run 'sentry auth login' to authenticate again, then remove the old file.", + `Run '${LOGIN_COMMAND}' to authenticate again, then remove the old file.`, ); } else { // Best-effort cleanup: SQLite metadata prevents re-import if it fails. diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index d20a84e1c..a36ac7eee 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -30,6 +30,7 @@ import { buildOrgSettingsUrl, buildSeerSettingsUrl, } from "./sentry-urls.js"; +import { LOGIN_COMMAND, URL_LOGIN_COMMAND } from "./login-command.js"; // --------------------------------------------------------------------------- // Exit code constants @@ -152,14 +153,14 @@ export class HostScopeError extends CliError { super( `${sourceOrMessage}: ${destinationUrl}\n` + "Refusing to route requests to this host because no Sentry credentials are configured for it.\n" + - `To use this host, run: sentry auth login --url ${destinationUrl}`, + `To use this host, run: ${URL_LOGIN_COMMAND} ${destinationUrl}`, EXIT.AUTH_HOST_SCOPE, ); } else { super( `${sourceOrMessage}: ${destinationUrl}\n` + `Refusing to route requests here because it doesn't match the host your Sentry credentials are for (${tokenHost}).\n` + - `To use this host, run: sentry auth login --url ${destinationUrl}\n` + + `To use this host, run: ${URL_LOGIN_COMMAND} ${destinationUrl}\n` + "To keep using your current credentials, remove this URL override.", EXIT.AUTH_HOST_SCOPE, ); @@ -272,9 +273,8 @@ export class AuthError extends CliError { options?: AuthErrorOptions, ) { const defaultMessages: Record = { - not_authenticated: "Not authenticated. Run 'sentry auth login' first.", - expired: - "Authentication expired. Run 'sentry auth login' to re-authenticate.", + not_authenticated: `Not authenticated. Run '${LOGIN_COMMAND}' first.`, + expired: `Authentication expired. Run '${LOGIN_COMMAND}' to re-authenticate.`, invalid: "Invalid authentication token.", }; const exitCodes: Record = { @@ -295,7 +295,7 @@ export class MalformedAuthTokenError extends AuthError { super( "invalid", "Invalid authentication token. Copy it again as a single line without spaces or control characters, " + - "or run 'sentry auth login' to replace stored credentials.", + `or run '${LOGIN_COMMAND}' to replace stored credentials.`, ); this.name = "MalformedAuthTokenError"; } diff --git a/packages/cli/src/lib/login-command.ts b/packages/cli/src/lib/login-command.ts new file mode 100644 index 000000000..d1b149b29 --- /dev/null +++ b/packages/cli/src/lib/login-command.ts @@ -0,0 +1,28 @@ +/** + * The sign-in commands that auth hints name. The standalone + * `@sentry/mcp-server` bundle has its own `auth login`, so its users are not + * sent to a CLI they may not have installed. + */ + +/** Set by `script/bundle-mcp-server.ts` to the `@sentry/mcp-server` version. */ +declare const SENTRY_MCP_SERVER_VERSION: string | undefined; + +/** The `@sentry/mcp-server` version when running as that bundle. */ +export const MCP_SERVER_VERSION = + typeof SENTRY_MCP_SERVER_VERSION === "undefined" + ? undefined + : SENTRY_MCP_SERVER_VERSION; + +/** Command that signs in to the active Sentry host. */ +export const LOGIN_COMMAND = MCP_SERVER_VERSION + ? "npx -y @sentry/mcp-server auth login" + : "sentry auth login"; + +/** + * Command that signs in and confirms a self-hosted host. The standalone + * `auth login` takes no `--url`, so the bundle points at the CLI, which + * shares the session. + */ +export const URL_LOGIN_COMMAND = MCP_SERVER_VERSION + ? "npx -y sentry auth login --url" + : "sentry auth login --url"; diff --git a/packages/cli/src/lib/login-host-guard.ts b/packages/cli/src/lib/login-host-guard.ts index 0ee77f688..052f3e21e 100644 --- a/packages/cli/src/lib/login-host-guard.ts +++ b/packages/cli/src/lib/login-host-guard.ts @@ -21,6 +21,7 @@ import { getDefaultUrl } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { normalizeUserInputToOrigin } from "./sentry-urls.js"; import { isHostTrusted, isLoginTrustAnchorFor } from "./token-host.js"; +import { URL_LOGIN_COMMAND } from "./login-command.js"; /** * Resolve the host a login without `--url` would target, using the same env @@ -91,6 +92,6 @@ export function buildHostRefusalMessage( return ( `Refusing to log in against ${host} — ${sourceClause}.\n\n` + "To authenticate against this self-hosted instance, confirm the host explicitly:\n" + - ` sentry auth login --url ${host}${tokenFlag}` + ` ${URL_LOGIN_COMMAND} ${host}${tokenFlag}` ); } diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index cea64f9c3..ccb381d29 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -88,8 +88,8 @@ 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: McpTarget, diff --git a/packages/cli/src/lib/mcp/usage.ts b/packages/cli/src/lib/mcp/usage.ts index c4e77ffc5..4bf9afe46 100644 --- a/packages/cli/src/lib/mcp/usage.ts +++ b/packages/cli/src/lib/mcp/usage.ts @@ -2,23 +2,13 @@ import type { Skill } from "@sentry/mcp-core/skills"; import { CLI_VERSION } from "../constants.js"; +import { LOGIN_COMMAND, MCP_SERVER_VERSION } from "../login-command.js"; -/** Set by `script/bundle-mcp-server.ts` to the `@sentry/mcp-server` version. */ -declare const SENTRY_MCP_SERVER_VERSION: string | undefined; - -/** Whether this code runs as the standalone `@sentry/mcp-server` bundle. */ -const IS_MCP_SERVER_PACKAGE = typeof SENTRY_MCP_SERVER_VERSION !== "undefined"; - -const COMMAND = IS_MCP_SERVER_PACKAGE ? "sentry-mcp" : "sentry mcp"; -const LOGIN_COMMAND = IS_MCP_SERVER_PACKAGE - ? "sentry-mcp auth login" - : "sentry auth login"; +const COMMAND = MCP_SERVER_VERSION ? "sentry-mcp" : "sentry mcp"; /** Build the `--version` output. */ export function buildMcpVersion(): string { - return IS_MCP_SERVER_PACKAGE - ? `${COMMAND} ${SENTRY_MCP_SERVER_VERSION}` - : `${COMMAND} ${CLI_VERSION}`; + return `${COMMAND} ${MCP_SERVER_VERSION ?? CLI_VERSION}`; } /** Build the `--help` text. */ diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 4fa143a3e..3c06f8da4 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -38,6 +38,7 @@ import { import { logger } from "./logger.js"; import { normalizeHttpOrigin } from "./sentry-urls.js"; import { getActiveTokenHost } from "./token-host.js"; +import { LOGIN_COMMAND } from "./login-command.js"; /** * Get the Sentry instance URL for OAuth endpoints. @@ -395,7 +396,7 @@ async function attemptPoll( case "expired": return { status: "error", - message: "Device code expired. Please run 'sentry auth login' again.", + message: `Device code expired. Please run '${LOGIN_COMMAND}' again.`, }; case "denied": return { @@ -574,7 +575,7 @@ export async function refreshAccessToken( if (rejected) { throw new AuthError( "expired", - "Session expired because the refresh credential was rejected. Run 'sentry auth login' to re-authenticate.", + `Session expired because the refresh credential was rejected. Run '${LOGIN_COMMAND}' to re-authenticate.`, ); } throw new ApiError( diff --git a/packages/cli/test/lib/mcp/usage-standalone.test.ts b/packages/cli/test/lib/mcp/usage-standalone.test.ts index 1f4368392..c6135930c 100644 --- a/packages/cli/test/lib/mcp/usage-standalone.test.ts +++ b/packages/cli/test/lib/mcp/usage-standalone.test.ts @@ -15,7 +15,17 @@ describe("standalone @sentry/mcp-server bundle", () => { expect(buildMcpVersion()).toBe("sentry-mcp 1.2.3"); const usage = buildMcpUsage(["inspect"]); expect(usage).toMatch(/^Usage: sentry-mcp /); - expect(usage).toContain("`sentry-mcp auth login`"); + expect(usage).toContain("`npx -y @sentry/mcp-server auth login`"); expect(usage).not.toContain("sentry mcp"); }); + + test("auth errors name the package's own login command", async () => { + vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); + vi.resetModules(); + const { AuthError } = await import("../../../src/lib/errors.js"); + + expect(new AuthError("not_authenticated").message).toBe( + "Not authenticated. Run 'npx -y @sentry/mcp-server auth login' first.", + ); + }); }); diff --git a/packages/mcp-server/test/bundle.test.mjs b/packages/mcp-server/test/bundle.test.mjs index aeaa44955..d8c047871 100644 --- a/packages/mcp-server/test/bundle.test.mjs +++ b/packages/mcp-server/test/bundle.test.mjs @@ -47,7 +47,14 @@ test("--help names the standalone command", () => { const result = run(["--help"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /^Usage: sentry-mcp /); - assert.match(result.stdout, /`sentry-mcp auth login`/); + assert.match(result.stdout, /`npx -y @sentry\/mcp-server auth login`/); +}); + +test("without credentials, points to its own auth login", () => { + const result = run([]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /npx -y @sentry\/mcp-server auth login/); + assert.doesNotMatch(result.stderr, /'sentry auth login'/); }); test("auth subcommands other than login point to the CLI", () => { From b83e10db35afd98eda20b1969ff3cd9c8470e68a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:07:05 +0000 Subject: [PATCH 10/29] docs(mcp): Lead stdio docs with @sentry/mcp-server, document one account per server Co-Authored-By: BYK --- AGENTS.md | 6 ++--- README.md | 2 +- docs/architecture/overview.md | 8 +++--- docs/mcp/README.md | 2 +- docs/operations/stdio-auth.md | 47 +++++++++++++++++++++-------------- docs/releases/stdio.md | 32 +++++++++++------------- packages/mcp-server/README.md | 30 +++++++++++----------- 7 files changed, 66 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ceb410cc9..a4c374916 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ toolkit/ │ ├── cli/ # Sentry CLI (see packages/cli/AGENTS.md) │ ├── toolkit-core/ # Shared CLI/MCP primitives │ ├── mcp-core/ # Core MCP implementation (private) -│ ├── mcp-server/ # `sentry mcp` bundled as @sentry/mcp-server +│ ├── mcp-server/ # stdio transport (@sentry/mcp-server on npm) │ ├── mcp-cloudflare/ # Web app + OAuth │ ├── mcp-server-evals/ # AI evaluation tests │ ├── mcp-server-mocks/ # MSW mocks @@ -70,7 +70,7 @@ toolkit/ - docs/architecture/overview.md — MCP system design - docs/operations/security.md — Authentication and security patterns -- docs/operations/stdio-auth.md — How `sentry mcp` reuses CLI auth and static tokens +- 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 @@ -98,7 +98,7 @@ toolkit/ **MCP releases:** -- docs/releases/stdio.md — `@sentry/mcp-server` and `sentry mcp` releases, and the MCP registry +- 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 10ad20ca0..f88d3322f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ 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. -The `stdio` transport ships as [`@sentry/mcp-server`](https://www.npmjs.com/package/@sentry/mcp-server): the `sentry mcp` server from the [`sentry` CLI](https://cli.sentry.dev), bundled into one file without the rest of the CLI. It uses your Sentry CLI login (`npx @sentry/mcp-server@latest auth login`, or `sentry auth login` if you have the CLI), or a static token from `--access-token` / `SENTRY_ACCESS_TOKEN`. `npx sentry@latest mcp` runs the same server with the same flags. +The `stdio` transport ships as [`@sentry/mcp-server`](https://www.npmjs.com/package/@sentry/mcp-server). Sign in once with `npx @sentry/mcp-server@latest auth login`, or pass a static token with `--access-token` / `SENTRY_ACCESS_TOKEN`. The login is shared with the [`sentry` CLI](https://cli.sentry.dev), so an existing `sentry auth login` works too. To use a static token, create a User Auth Token in Sentry with the necessary scopes. As of writing this is: diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index f851fe224..211c3db42 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -15,7 +15,7 @@ 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/ # `sentry mcp` bundled 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 @@ -67,9 +67,9 @@ The local stdio MCP server is the `sentry mcp` command in the Sentry CLI. ### packages/mcp-server -Publishes `sentry mcp` as `@sentry/mcp-server`: 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). +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 diff --git a/docs/mcp/README.md b/docs/mcp/README.md index 169530a7a..7914c891c 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -1,6 +1,6 @@ # Sentry MCP Documentation -MCP spans `packages/mcp-core/`, `sentry mcp` in `packages/cli/`, 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/stdio-auth.md b/docs/operations/stdio-auth.md index 0c1b3b687..a24f64b34 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -1,10 +1,11 @@ # Stdio Authentication -How the local stdio MCP server (`sentry mcp`) authenticates with Sentry. +How the stdio MCP server (`@sentry/mcp-server`) authenticates with Sentry. -`sentry mcp` has no auth flow of its own. It reuses the Sentry CLI's -credentials, so `sentry auth login`, `sentry auth logout`, and -`sentry auth status` manage the MCP server's credentials too. +The server has no auth flow or token cache of its own. It reuses the Sentry +CLI's credential store: `npx -y @sentry/mcp-server auth login` runs the CLI's +device flow, and a login from `sentry auth login` works too. The same server +also runs as `sentry mcp` in the CLI. ## Credential Sources @@ -13,10 +14,10 @@ In order of precedence: 1. **Static token** — `--access-token` flag, then `SENTRY_ACCESS_TOKEN`. The token is used as given for the life of the server and is never refreshed. Org auth tokens (`sntrys_`) record the Sentry URL they were - issued for; `sentry mcp` refuses to send one to a different host - (`HostScopeError`). `SENTRY_ACCESS_TOKEN` only affects `sentry mcp`; other + issued for; the server refuses to send one to a different host + (`HostScopeError`). `SENTRY_ACCESS_TOKEN` only affects the MCP server; other CLI commands ignore it. -2. **CLI credentials** — the stored `sentry auth login` session, or +2. **Shared login** — the stored account from `auth login`, or `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` (see the CLI's `SENTRY_FORCE_ENV_TOKEN` rules). These go through the CLI's `refreshToken()`, which enforces the same host scoping as every CLI command. @@ -24,8 +25,8 @@ In order of precedence: later calls use the new token, so a long-running server survives token rotation without a restart. -When no credential is available and stdin is a TTY, `sentry mcp` starts the -CLI's interactive login before serving. MCP clients launch the server over +When no credential is available and stdin is a TTY, the server starts the +interactive login before serving. MCP clients launch the server over pipes, so in that case it exits with an error that names the login command: `npx -y @sentry/mcp-server auth login` from `@sentry/mcp-server`, and `sentry auth login` from `sentry mcp`. Other auth hints follow the same rule @@ -33,21 +34,29 @@ pipes, so in that case it exits with an error that names the login command: `--url` always names `npx -y sentry auth login --url`, because the standalone `auth login` takes no flags. -For self-hosted Sentry, set the host with `--host`/`--url` (or the CLI's -`SENTRY_HOST`/`SENTRY_URL`). Pair `--host` with `--insecure-http` for instances +For self-hosted Sentry, set the host with `--host`/`--url` or +`SENTRY_HOST`/`SENTRY_URL`. Only the environment variables also pick the stored +account for that server (see [Accounts](#accounts)). Pair `--host` with `--insecure-http` for instances that only expose plain HTTP. -## Migration From `@sentry/mcp-server` +## Accounts -The standalone package's device code flow, bundled client ID, and token cache -(`~/.sentry/mcp.json`, `SENTRY_MCP_AUTH_CACHE`) are gone. `sentry mcp` does not -read the old cache. Users who relied on it run `npx -y @sentry/mcp-server auth login` (or -`sentry auth login`) once, or keep passing `--access-token` / +The credential store can hold several accounts, but each server process uses +one: the account resolved at startup. With `SENTRY_HOST`/`SENTRY_URL` set, that +is a stored account on that server; otherwise it is the active account +(`sentry auth switch`). Refreshes after a 401 stay on that account, and tool +calls never pick a different one. To use two accounts at once, configure two +MCP servers, for example one per host via `SENTRY_HOST`, or one with a static `SENTRY_ACCESS_TOKEN`. -`@sentry/mcp-server` now bundles this same server and shares the CLI's -credential store. `sentry-mcp auth login` runs the CLI's device flow; use -`npx -y sentry auth logout|status` for the rest. +## Upgrading From Older `@sentry/mcp-server` Versions + +Older releases had their own device code flow, bundled client ID, and token +cache (`~/.sentry/mcp.json`, `SENTRY_MCP_AUTH_CACHE`). Current releases do not +read that cache. Run `npx -y @sentry/mcp-server auth login` once, or keep +passing `--access-token` / `SENTRY_ACCESS_TOKEN`. `auth logout` and +`auth status` are not part of the package; use `npx -y sentry auth logout` or +`npx -y sentry auth status`. ## Key Files diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index 98374b87e..9a4adbe56 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -4,32 +4,29 @@ How the local stdio MCP server ships. ## Overview -The stdio MCP server is implemented in the CLI (`packages/cli`) and ships two -ways: +The stdio MCP server ships as `@sentry/mcp-server`, released from the +`packages/mcp-server` Craft workspace. Its source lives in `packages/cli` so it +shares the CLI's auth, which also exposes it as `sentry mcp` in each CLI +release. -- `@sentry/mcp-server`, the recommended install: the server bundled into one - file (see below), released from the `packages/mcp-server` Craft workspace. -- `sentry mcp`, the same server as a CLI command, released with every CLI - version (the `packages/cli` Craft workspace). +It bundles `@sentry/mcp-core` (tools, server, and the stdio transport) and +accepts these credentials: -`sentry mcp` bundles `@sentry/mcp-core` (tools, server, and the stdio -transport) and uses the CLI's own authentication: - -- `sentry auth login` session (refreshed automatically, including after an - upstream 401) +- the shared login from `npx @sentry/mcp-server auth login` or `sentry auth + login` (refreshed automatically, including after an upstream 401) - `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`, as for every CLI command - `--access-token` / `SENTRY_ACCESS_TOKEN`: a static token for the MCP server only. It is never refreshed, and org auth tokens (`sntrys_`) are still scoped to the host they were issued for. -## `@sentry/mcp-server` package +## Bundle -`packages/mcp-server` publishes the same server as a single file with no -runtime dependencies. `packages/cli/script/bundle-mcp-server.ts` bundles +The package is a single file with no runtime dependencies. +`packages/cli/script/bundle-mcp-server.ts` bundles `packages/cli/src/mcp-server.ts` into `dist/sentry-mcp.cjs`: -- Same flags, environment variables, tools, and self-hosted support as - `sentry mcp`, and the same CLI session and credential store. +- Full flags, environment variables, tools, and self-hosted support, using the + CLI's credential store. - `sentry-mcp auth login` runs the CLI's device flow with plain-text output. Other `auth` subcommands point to `npx -y sentry auth ...`. - CLI-only code stays out: commands, the markdown renderer, agent skills, @@ -83,8 +80,7 @@ Users run the server through npx in their MCP client configuration: Sign in once with `npx @sentry/mcp-server@latest auth login` (or `sentry auth login`), or add `"env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." }`. -Pin a version with `@sentry/mcp-server@0.42.0`. `npx sentry@latest mcp` runs the -same server. +Pin a version with `@sentry/mcp-server@0.42.0`. ## Testing Before a Release diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index ded5eec62..803928575 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -1,13 +1,12 @@ # @sentry/mcp-server -The Sentry stdio MCP server as a single file with no runtime dependencies. It -is the Sentry CLI's `sentry mcp` server, bundled without the rest of the CLI, -so it works with sentry.io and self-hosted Sentry. +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"] + "args": ["-y", "@sentry/mcp-server@latest"] } ``` @@ -15,21 +14,22 @@ Requires Node.js 22.15 or later. ## Authentication -- `npx -y @sentry/mcp-server auth login` signs in with the Sentry CLI's device - flow. The session is shared with the `sentry` CLI, so `sentry auth login` - works too. +- `npx -y @sentry/mcp-server auth login` signs in once. The login is shared + with the [`sentry` CLI](https://cli.sentry.dev), so an existing + `sentry auth login` works too. - `--access-token` / `SENTRY_ACCESS_TOKEN` passes a static token instead. -- For account management (`logout`, `status`, switching accounts), use - `npx -y sentry auth ...`. +- Each server process uses one account. Log out, check status, or switch + accounts with `npx -y sentry auth logout|status|switch`. ## Options -The flags and environment variables are the same as `sentry mcp`; run -`npx -y @sentry/mcp-server --help` for the list. For self-hosted Sentry, pass -`--host` or `--url`, or set `SENTRY_HOST` / `SENTRY_URL`. +Run `npx -y @sentry/mcp-server --help` for the flags and environment +variables. For self-hosted Sentry, pass `--host` or `--url`, or set +`SENTRY_HOST` / `SENTRY_URL`. ## Development -The source lives in `packages/cli`. `pnpm run bundle` builds -`dist/sentry-mcp.cjs` from `packages/cli/src/mcp-server.ts`, and -`pnpm run test:bundle` smoke-tests the result. +The source lives in `packages/cli` so the server shares the CLI's auth. +`pnpm run bundle` builds `dist/sentry-mcp.cjs` from +`packages/cli/src/mcp-server.ts`, and `pnpm run test:bundle` smoke-tests the +result. From d43709b58e36bf70a2737c0c9b96064f34649302 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:35:49 +0000 Subject: [PATCH 11/29] fix(mcp): Pin token refreshes to the account the server started with After an upstream 401 the refresh callback resolved the effective CLI account again, so another process switching accounts on the same host made later tool calls run as the other account. The host check cannot tell two accounts on one server apart. Startup now records the stored account (id and user) or the env token it used. Refreshes read only that credential and fail closed when the account is removed, another user logs in over it, or it falls back to the env token. --- docs/operations/stdio-auth.md | 5 +- packages/cli/src/lib/mcp.ts | 101 ++++++++++++++++++---- packages/cli/test/lib/mcp-auth.test.ts | 10 ++- packages/cli/test/lib/mcp-start.test.ts | 108 +++++++++++++++++++++++- 4 files changed, 204 insertions(+), 20 deletions(-) diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index a24f64b34..0fe34e9d2 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -45,7 +45,10 @@ The credential store can hold several accounts, but each server process uses one: the account resolved at startup. With `SENTRY_HOST`/`SENTRY_URL` set, that is a stored account on that server; otherwise it is the active account (`sentry auth switch`). Refreshes after a 401 stay on that account, and tool -calls never pick a different one. To use two accounts at once, configure two +calls never pick a different one, even when another process switches or adds +accounts. If that account is removed or another user logs in over it, the +refresh fails and tool calls keep failing until the server restarts. To use +two accounts at once, configure two MCP servers, for example one per host via `SENTRY_HOST`, or one with a static `SENTRY_ACCESS_TOKEN`. diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index ccb381d29..9dd3a98c4 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -1,8 +1,14 @@ import { normalizeAuthToken } from "./auth-header.js"; import { getConfiguredSentryUrl } from "./constants.js"; -import { refreshToken } from "./db/auth.js"; +import { getAccountScope } from "./db/account-scope.js"; +import { getAccountRow } from "./db/accounts.js"; +import { + type AuthSource, + getEffectiveAccountRow, + refreshToken, +} from "./db/auth.js"; import { getEnv } from "./env.js"; -import { HostScopeError, ValidationError } from "./errors.js"; +import { AuthError, HostScopeError, ValidationError } from "./errors.js"; import { parseSntrysClaim } from "./token-claims.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; @@ -52,19 +58,62 @@ export function prepareMcpServerArgs( } } +/** + * The CLI credential a server process started with. Every later refresh stays + * on it, so another process running `sentry auth switch` or logging in to a + * different account cannot change who the running server acts as. + */ +export type McpCredentialPin = + | { kind: "stored"; accountId: string; userId: string | null } + | { kind: "env" }; + +export type McpCliCredential = { token: string; pin: McpCredentialPin }; + +const ACCOUNT_CHANGED_MESSAGE = + "The account this MCP server started with is no longer available. Restart the MCP server to use the current CLI login."; + +/** + * 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; +} + /** * 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( +export async function resolveCliMcpCredential( config: McpTarget, - options: { force?: boolean } = {}, -): Promise { + options: { force?: boolean; pin?: McpCredentialPin } = {}, +): Promise { const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`; - const { token } = await refreshToken({ force: options.force }); + if (options.pin) { + applyCredentialPin(options.pin); + } + const result = await refreshToken({ force: options.force }); const tokenHost = getActiveTokenHost(); if (!(tokenHost && isHostTrusted(targetUrl, tokenHost))) { @@ -75,10 +124,27 @@ export async function resolveCliMcpAccessToken( ); } + const pin = options.pin ?? pinFromSource(result.source); + // A stored credential that can no longer refresh falls back to the env + // token; that is a different identity, so refuse it. + const resolvedKind = result.source === "oauth" ? "stored" : "env"; + if (pin.kind !== resolvedKind) { + 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 }; +} + +function pinFromSource(source: AuthSource): McpCredentialPin { + const row = source === "oauth" ? getEffectiveAccountRow() : undefined; + return row + ? { kind: "stored", accountId: row.account_id, userId: row.user_id } + : { kind: "env" }; } /** @@ -91,11 +157,11 @@ export async function resolveCliMcpAccessToken( * it rethrows the original {@link AuthError} so the client sees which login * command to run instead of a hung browser prompt. */ -async function resolveMcpAccessTokenWithLogin( +async function resolveMcpCredentialWithLogin( config: McpTarget, -): Promise { +): Promise { try { - return await resolveCliMcpAccessToken(config); + return await resolveCliMcpCredential(config); } catch (error) { const { isatty } = await import("node:tty"); const { shouldAutoAuth, assertAutoLoginHostTrusted } = @@ -120,7 +186,7 @@ async function resolveMcpAccessTokenWithLogin( throw error; } - return await resolveCliMcpAccessToken(config); + return await resolveCliMcpCredential(config); } } @@ -178,11 +244,14 @@ export async function startMcpServer(args: string[]): Promise { return true; } + const { token, pin } = await resolveMcpCredentialWithLogin(config); serveMcpStdio(config, { - accessToken: await resolveMcpAccessTokenWithLogin(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. - refreshAccessToken: () => resolveCliMcpAccessToken(config, { force: true }), + // the current token, so mirror `--follow`'s refresh-on-401 behavior. The + // pin keeps the refresh on the startup account. + refreshAccessToken: async () => + (await resolveCliMcpCredential(config, { force: true, pin })).token, env, }); return true; diff --git a/packages/cli/test/lib/mcp-auth.test.ts b/packages/cli/test/lib/mcp-auth.test.ts index e0388a2d4..1566a5db7 100644 --- a/packages/cli/test/lib/mcp-auth.test.ts +++ b/packages/cli/test/lib/mcp-auth.test.ts @@ -7,7 +7,7 @@ import { } from "../../src/lib/errors.js"; import { prepareMcpServerArgs, - resolveCliMcpAccessToken, + resolveCliMcpCredential, startMcpServer, } from "../../src/lib/mcp.js"; import { @@ -33,7 +33,13 @@ afterEach(async () => { await resetHostScopingState(); }); -describe("resolveCliMcpAccessToken", () => { +async function resolveCliMcpAccessToken( + config: Parameters[0], +): Promise { + return (await resolveCliMcpCredential(config)).token; +} + +describe("resolveCliMcpCredential", () => { test("returns the active CLI session token for its scoped host", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index f8bf19a55..f80ae4e0d 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { clearAuth, 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, @@ -26,6 +32,7 @@ vi.mock("../../src/lib/mcp/server.js", async (importOriginal) => ({ useTestConfigDir("mcp-start-"); useEnvSandbox([ + "SENTRY_CLIENT_ID", "SENTRY_ACCESS_TOKEN", "SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", @@ -80,6 +87,105 @@ describe("startMcpServer credential wiring", () => { }); }); +describe("startMcpServer account pinning", () => { + const originalFetch = globalThis.fetch; + const refreshedWith: string[] = []; + + /** Store a new account on sentry.io, as `sentry auth login` does. */ + function login(token: string, userId: string): string { + const id = setAuthToken(token, 3600, `${token}-refresh`, { + host: "https://sentry.io", + newAccount: true, + }); + setUserInfo({ userId, email: `${userId}@example.com` }); + return id; + } + + beforeEach(async () => { + await clearAuth(); + refreshedWith.length = 0; + process.env.SENTRY_CLIENT_ID = "test-client-id"; + // Each refresh rotates the access token of whichever refresh token it got. + globalThis.fetch = async (input, init) => { + expect(new Request(input).url).toBe("https://sentry.io/oauth/token/"); + const body = init?.body; + const refresh = + body instanceof URLSearchParams ? body.get("refresh_token") : null; + refreshedWith.push(refresh ?? ""); + return Response.json({ + access_token: `${refresh?.replace(/-refresh$/, "")}-rotated`, + token_type: "bearer", + expires_in: 3600, + refresh_token: refresh, + }); + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + 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("keeps an env token after a stored login appears", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_HOST = "https://sentry.io"; + const { options } = await start([]); + expect(options.accessToken).toBe("env-token"); + + login("stored-token", "1"); + + await expect(options.refreshAccessToken?.()).resolves.toBe("env-token"); + }); +}); + describe("startMcpServer static tokens", () => { test("uses --access-token without a CLI session and never refreshes it", async () => { await clearAuth(); From e2f60b2d7cb0914e765388a89e0d53a3bcace206 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:46:55 +0000 Subject: [PATCH 12/29] fix(mcp): Check the pinned account's identity on every refresh The startup pin was read from the effective account after the refresh, so an account switch between the two could pin a different account than the one whose token was returned. Later refreshes only checked stored vs env, and the CLI's account selection falls back to the active account when the pinned row disappears mid-refresh. Startup now pins the effective account before refreshing. Every refresh must return a credential whose identity matches the pinned row's current credential, or it fails closed. --- packages/cli/src/lib/mcp.ts | 62 ++++++++++++++++++------- packages/cli/test/lib/mcp-start.test.ts | 58 +++++++++++++++++++++++ 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index 9dd3a98c4..02b549e49 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -1,10 +1,10 @@ import { normalizeAuthToken } from "./auth-header.js"; import { getConfiguredSentryUrl } from "./constants.js"; import { getAccountScope } from "./db/account-scope.js"; -import { getAccountRow } from "./db/accounts.js"; +import { accountIdentity, getAccountRow } from "./db/accounts.js"; import { - type AuthSource, getEffectiveAccountRow, + type RefreshTokenResult, refreshToken, } from "./db/auth.js"; import { getEnv } from "./env.js"; @@ -72,6 +72,8 @@ 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 @@ -96,6 +98,37 @@ function applyCredentialPin(pin: McpCredentialPin): void { 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. @@ -110,8 +143,9 @@ export async function resolveCliMcpCredential( options: { force?: boolean; pin?: McpCredentialPin } = {}, ): Promise { const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`; - if (options.pin) { - applyCredentialPin(options.pin); + const requested = options.pin ?? startupPin(); + if (requested) { + applyCredentialPin(requested); } const result = await refreshToken({ force: options.force }); const tokenHost = getActiveTokenHost(); @@ -124,11 +158,14 @@ export async function resolveCliMcpCredential( ); } - const pin = options.pin ?? pinFromSource(result.source); - // A stored credential that can no longer refresh falls back to the env - // token; that is a different identity, so refuse it. - const resolvedKind = result.source === "oauth" ? "stored" : "env"; - if (pin.kind !== resolvedKind) { + // 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, }); @@ -140,13 +177,6 @@ export async function resolveCliMcpCredential( return { token: normalizeAuthToken(result.token), pin }; } -function pinFromSource(source: AuthSource): McpCredentialPin { - const row = source === "oauth" ? getEffectiveAccountRow() : undefined; - return row - ? { kind: "stored", accountId: row.account_id, userId: row.user_id } - : { kind: "env" }; -} - /** * Resolve the MCP credential, falling back to the interactive login flow when * the CLI has no usable session and the terminal is interactive. diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index f80ae4e0d..7343ebcb2 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -23,6 +23,29 @@ type Served = { config: McpServerConfig; options: McpServeOptions }; const served: { calls: Served[] } = { calls: [] }; +/** + * 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((config: McpServerConfig, options: McpServeOptions) => { @@ -123,6 +146,8 @@ describe("startMcpServer account pinning", () => { afterEach(() => { globalThis.fetch = originalFetch; + refreshHooks.before = undefined; + refreshHooks.after = undefined; }); test("keeps refreshing the startup account after another process switches accounts", async () => { @@ -174,6 +199,39 @@ describe("startMcpServer account pinning", () => { 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"; From d8f5d236ee9a4ff9b5500ff4253869ac37900e26 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:20:54 +0000 Subject: [PATCH 13/29] fix(mcp): Apply the .sentryclirc URL trust gate before serving `sentry mcp` and `@sentry/mcp-server` skip buildCommand, so the rc-URL check every other CLI command runs never fired. A repo-local `.sentryclirc` could point the server at another host, and a static token without a host claim was sent there. startMcpServer now calls assertRcUrlTrusted unless an explicit --host/--url overrides the target, so a non-SaaS rc URL must match a server the user is logged in to. --- docs/operations/stdio-auth.md | 6 +++ packages/cli/src/lib/mcp.ts | 10 ++++ packages/cli/test/lib/mcp-start.test.ts | 71 +++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index 0fe34e9d2..14c9f2afc 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -39,6 +39,12 @@ For self-hosted Sentry, set the host with `--host`/`--url` or account for that server (see [Accounts](#accounts)). Pair `--host` with `--insecure-http` for instances that only expose plain HTTP. +A repo-local `.sentryclirc` URL is not a trust source. Unless `--host`/`--url` +is passed, a non-SaaS `[defaults] url` must match a server you are logged in +to, the same check every CLI command applies. Otherwise the server refuses to +start (`HostScopeError`) instead of sending any token, including a static one, +to that host. + ## Accounts The credential store can hold several accounts, but each server process uses diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index 02b549e49..c0fe19972 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -264,6 +264,16 @@ export async function startMcpServer(args: string[]): Promise { return false; } + // A repo-local `.sentryclirc` can set the target URL, but it is not a trust + // source: without this gate, a static token (which carries no host claim + // unless it is an org token) would be sent to whatever server the file + // names. Apply the same rc-URL check as every other CLI command, unless an + // explicit `--host`/`--url` flag overrides the target. + if (!hasSentryTargetArg(args)) { + const { assertRcUrlTrusted } = await import("./sentryclirc.js"); + await assertRcUrlTrusted(process.cwd()); + } + const { config } = invocation; const { accessToken } = config; if (accessToken) { diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 7343ebcb2..24694c838 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -1,3 +1,6 @@ +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 { clearAuth, @@ -11,6 +14,10 @@ import { 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, @@ -340,6 +347,70 @@ describe("startMcpServer static tokens", () => { }); }); +describe("startMcpServer .sentryclirc URLs", () => { + /** Run from a directory whose `.sentryclirc` points at `url`. */ + async function inRcDir(url: string): Promise { + const dir = mkdtempSync(join(tmpdir(), "mcp-start-rc-")); + writeFileSync(join(dir, ".sentryclirc"), `[defaults]\nurl = ${url}\n`); + rcDirs.push(dir); + vi.spyOn(process, "cwd").mockReturnValue(dir); + clearSentryCliRcCache(); + await applySentryCliRcEnvShim(dir); + } + + const rcDirs: string[] = []; + + beforeEach(async () => { + await clearAuth(); + }); + + afterEach(() => { + clearSentryCliRcCache(); + for (const dir of rcDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("refuses to send a static token to a self-hosted URL from a repo .sentryclirc", async () => { + await inRcDir("https://evil.example.com"); + process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; + + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await expect(startMcpServer([])).rejects.toBeInstanceOf(HostScopeError); + await expect( + startMcpServer(["--access-token=static-token"]), + ).rejects.toBeInstanceOf(HostScopeError); + expect(served.calls).toHaveLength(0); + }); + + test("serves a static token when an explicit --host overrides the rc URL", async () => { + await inRcDir("https://evil.example.com"); + + const { config } = await start([ + "--access-token=static-token", + "--host=sentry.example.com", + ]); + expect(config.sentryHost).toBe("sentry.example.com"); + }); + + test("serves a static token for a SaaS rc URL", async () => { + await inRcDir("https://sentry.io"); + + const { options } = await start(["--access-token=static-token"]); + expect(options.accessToken).toBe("static-token"); + }); + + test("serves a static token for an rc URL the user has logged in to", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.example.com", + }); + await inRcDir("https://sentry.example.com"); + + const { config } = await start(["--access-token=static-token"]); + expect(config.sentryHost).toBe("sentry.example.com"); + }); +}); + describe("startMcpServer output flags", () => { test.each([["--help"], ["--version"]])( "prints %s to stdout without serving", From 44563a26f5aece851b72032480b1b66f5942f302 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:55:37 +0000 Subject: [PATCH 14/29] fix(mcp): Keep fatal exit codes and handle closed pipes in the bundle A SIGINT/SIGTERM during a fatal error's flush reached shutdown's process.exit(0) and reported success after a crash. shutdown now shares the exiting flag, so the fatal path's exit(1) wins. The standalone @sentry/mcp-server entry never registered the EPIPE/EIO stream handlers bin.ts uses, so a client closing its pipes during setup crashed with an unhandled stream error. Both entries now share installStreamErrorHandlers from lib/stream-errors.ts. --- packages/cli/src/bin.ts | 20 +---- packages/cli/src/lib/mcp/server.ts | 17 ++++- packages/cli/src/lib/stream-errors.ts | 28 +++++++ packages/cli/src/mcp-server.ts | 5 ++ .../cli/test/lib/mcp/process-handlers.test.ts | 73 +++++++++++++++++++ packages/mcp-server/test/bundle.test.mjs | 14 ++++ 6 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/lib/stream-errors.ts create mode 100644 packages/cli/test/lib/mcp/process-handlers.test.ts diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 409dd66b5..d5100c159 100755 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -3,12 +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`. */ // Must stay the first import: later imports can load node:sqlite. import "./lib/suppress-sqlite-warning.js"; import { startCli } from "./cli.js"; import { wrapCall } from "./lib/react-native/wrap-call.js"; +import { installStreamErrorHandlers } from "./lib/stream-errors.js"; // React Native Xcode build wrapper: when the RN build script invokes us in // place of NODE_BINARY/HERMES_CLI_PATH (see `react-native xcode`), forward to @@ -18,23 +20,7 @@ if (process.env.__SENTRY_RN_WRAP_XCODE_CALL === "1") { process.exit(wrapCall()); } -// Handle non-recoverable stream I/O errors gracefully instead of crashing. -// - EPIPE (errno -32): downstream pipe consumer closed (e.g., `sentry issue list | head`). -// Normal Unix behavior — not an error. Exit 0 because the CLI succeeded. -// - EIO (errno -5): low-level I/O failure on the stream fd. -// Non-recoverable — exit 1 so callers know the output was lost. -function handleStreamError(err: NodeJS.ErrnoException): void { - if (err.code === "EPIPE") { - process.exit(0); - } - if (err.code === "EIO") { - process.exit(1); - } - throw err; -} - -process.stdout.on("error", handleStreamError); -process.stderr.on("error", handleStreamError); +installStreamErrorHandlers(); // startCli handles its own errors — this is a safety net for truly unexpected rejections startCli().catch(() => { diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 2e74c94de..e1345f497 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -267,8 +267,15 @@ function initMcpTelemetry( }); } -/** Exit on fatal errors without recursing when stderr is already closed. */ -function installProcessHandlers(): (error: unknown, label: string) => void { +/** + * 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) { @@ -297,6 +304,12 @@ function installProcessHandlers(): (error: unknown, label: string) => void { } 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); 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/mcp-server.ts b/packages/cli/src/mcp-server.ts index f2d34706a..fc48c475c 100644 --- a/packages/cli/src/mcp-server.ts +++ b/packages/cli/src/mcp-server.ts @@ -11,6 +11,11 @@ import "./lib/suppress-sqlite-warning.js"; import { redactCredentialText } from "./lib/credential-redaction.js"; import { CliError, formatError, getExitCode } from "./lib/errors.js"; import { preloadProjectContext } from "./lib/project-context.js"; +import { installStreamErrorHandlers } from "./lib/stream-errors.js"; + +// Same as `bin.ts`: an MCP client closing its pipes is a normal disconnect, +// not a crash, including during setup before the server's own handlers exist. +installStreamErrorHandlers(); async function main(args: string[]): Promise { try { 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/mcp-server/test/bundle.test.mjs b/packages/mcp-server/test/bundle.test.mjs index d8c047871..22b54faab 100644 --- a/packages/mcp-server/test/bundle.test.mjs +++ b/packages/mcp-server/test/bundle.test.mjs @@ -50,6 +50,20 @@ test("--help names the standalone command", () => { assert.match(result.stdout, /`npx -y @sentry\/mcp-server auth login`/); }); +test("a client closing stdout early is a clean exit, not a crash", async () => { + const child = spawn(process.execPath, [bin, "--help"], { + stdio: ["ignore", "pipe", "pipe"], + env, + }); + child.stdout.destroy(); + const chunks = []; + child.stderr.on("data", (chunk) => chunks.push(chunk)); + const code = await new Promise((resolve) => child.on("exit", resolve)); + const stderr = Buffer.concat(chunks).toString(); + assert.equal(code, 0, stderr); + assert.doesNotMatch(stderr, /EPIPE/); +}); + test("without credentials, points to its own auth login", () => { const result = run([]); assert.notEqual(result.status, 0); From ebb182f8b6134ac967c6f125e3c7cc95e8d9c30d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:24:43 +0000 Subject: [PATCH 15/29] fix(mcp): Make dev:stdio launch the CLI MCP server The stdio host moved into the CLI, so the old watch-only dev:stdio never started a server. Build mcp-core and its deps with output on stderr, then run `sentry mcp` from source so stdout stays JSON-RPC only. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e4a710b1..af36558aa 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "docs:check": "node scripts/check-doc-links.mjs", "test:ci-projects": "node --test scripts/ci-projects.test.mjs scripts/check-workspace-deps.test.mjs scripts/cli-nightly-version.test.mjs scripts/cli-nightly-watchdog.test.mjs scripts/cloudflare-deployment.test.mjs scripts/deploy-workflow.test.mjs scripts/migrate-cloudflare-token.test.mjs scripts/oxc-config.test.mjs scripts/cli-lint-rules.test.mjs", "dev": "pnpm --filter '@sentry/mcp-cloudflare...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-cloudflare --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", - "dev:stdio": "pnpm --filter '@sentry/mcp-core...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", + "dev:stdio": "pnpm --filter '@sentry/mcp-core...' --if-present run build 1>&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", From 4c673d36005bddccd035cece51f140449a254d07 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:59:42 +0000 Subject: [PATCH 16/29] fix(mcp): Trust matching org tokens for rc targets and keep the rc shim on DB failures An org auth token names the server it was issued for, and resolveStaticMcpAccessToken already checks that claim against the target. Resolve the static token first and skip the .sentryclirc trust gate for claimed tokens, so a matching sntrys_ token can serve a self-hosted rc target without a CLI session. Claimless static tokens are still gated. The project-root cache write is an optimization; a database failure no longer skips applySentryCliRcEnvShim during boot. --- docs/operations/stdio-auth.md | 4 +- packages/cli/src/lib/mcp.ts | 33 +++++++----- packages/cli/src/lib/project-context.ts | 17 ++++-- packages/cli/test/lib/mcp-start.test.ts | 32 +++++++++++ packages/cli/test/lib/project-context.test.ts | 54 +++++++++++++++++++ 5 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 packages/cli/test/lib/project-context.test.ts diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index 14c9f2afc..92f905241 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -43,7 +43,9 @@ A repo-local `.sentryclirc` URL is not a trust source. Unless `--host`/`--url` is passed, a non-SaaS `[defaults] url` must match a server you are logged in to, the same check every CLI command applies. Otherwise the server refuses to start (`HostScopeError`) instead of sending any token, including a static one, -to that host. +to that host. An org auth token (`sntrys_`) 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 diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index c0fe19972..7d4b6e92e 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -264,23 +264,32 @@ export async function startMcpServer(args: string[]): Promise { return false; } + const { config } = invocation; + const staticToken = config.accessToken + ? resolveStaticMcpAccessToken({ + ...config, + accessToken: config.accessToken, + }) + : undefined; + // A repo-local `.sentryclirc` can set the target URL, but it is not a trust - // source: without this gate, a static token (which carries no host claim - // unless it is an org token) would be sent to whatever server the file - // names. Apply the same rc-URL check as every other CLI command, unless an - // explicit `--host`/`--url` flag overrides the target. - if (!hasSentryTargetArg(args)) { + // source: without this gate, a static 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 token is an org token whose host claim + // `resolveStaticMcpAccessToken` already matched against the target. + if ( + !( + hasSentryTargetArg(args) || + (staticToken && parseSntrysClaim(staticToken)) + ) + ) { const { assertRcUrlTrusted } = await import("./sentryclirc.js"); await assertRcUrlTrusted(process.cwd()); } - const { config } = invocation; - const { accessToken } = config; - if (accessToken) { - serveMcpStdio(config, { - accessToken: resolveStaticMcpAccessToken({ ...config, accessToken }), - env, - }); + if (staticToken) { + serveMcpStdio(config, { accessToken: staticToken, env }); return true; } diff --git a/packages/cli/src/lib/project-context.ts b/packages/cli/src/lib/project-context.ts index 4bb08ec24..1276ac65f 100644 --- a/packages/cli/src/lib/project-context.ts +++ b/packages/cli/src/lib/project-context.ts @@ -25,10 +25,19 @@ export async function preloadProjectContext(cwd: string): Promise { ]); const result = await findProjectRoot(cwd); - await setCachedProjectRoot(cwd, { - projectRoot: result.projectRoot, - reason: result.reason, - }); + // 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) { + const { logger } = await import("./logger.js"); + logger + .withTag("project-context") + .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 diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 24694c838..5ea6ccdaf 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -400,6 +400,38 @@ describe("startMcpServer .sentryclirc URLs", () => { 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); + + served.calls = []; + process.env.SENTRY_ACCESS_TOKEN = token; + const fromEnv = await start([]); + expect(fromEnv.options.accessToken).toBe(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", + }); + + const { startMcpServer } = await import("../../src/lib/mcp.js"); + await expect( + startMcpServer([`--access-token=${token}`]), + ).rejects.toBeInstanceOf(HostScopeError); + expect(served.calls).toHaveLength(0); + }); + 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", 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"); +}); From aeb2251197e13b7a068fdc78bf8973369141eff8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:49:20 +0000 Subject: [PATCH 17/29] ref(mcp): Publish @sentry/mcp-server with every CLI release The package bundles the CLI's MCP server, so the CLI release now publishes it at the CLI's version instead of running a separate release line. - bump-version.ts sets packages/mcp-server to the CLI version on --pre and --post; the bundle script fails if the two drift apart. - cli-build.yml bundles, tests, and packs the package next to the CLI's npm package; the packages/cli Craft workspace publishes it to npm. - Drop the packages/mcp-server Craft workspace, the release workflow's mcp option, and merge-jobs' packing for it. - The MCP registry workflow now runs on cli@ releases. The private mcp-* packages keep their own versions. Co-Authored-By: BYK --- .craft.yml | 22 ++--- .github/workflows/cli-build.yml | 13 +++ .github/workflows/mcp-registry.yml | 27 ++++-- .github/workflows/merge-jobs.yml | 22 +---- .github/workflows/release.yml | 39 +-------- docs/releases/stdio.md | 39 ++++++--- packages/cli/script/bump-version.ts | 22 ++++- packages/cli/script/bundle-mcp-server.ts | 9 ++ packages/cli/test/script/bump-version.test.ts | 85 +++++++++++++++---- packages/mcp-server/package.json | 2 +- 10 files changed, 167 insertions(+), 113 deletions(-) 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..f8edc485d 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 @@ -25,7 +25,7 @@ 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 +33,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 +42,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/merge-jobs.yml b/.github/workflows/merge-jobs.yml index 980e486c8..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: @@ -34,26 +34,6 @@ jobs: - name: Run tests run: pnpm test:ci - # @sentry/mcp-server bundles the CLI's `sentry mcp` server into one file. - - name: Bundle mcp-server - env: - SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} - run: pnpm --filter @sentry/mcp-server run bundle - - - name: Test mcp-server bundle - run: pnpm --filter @sentry/mcp-server run test:bundle - - - name: Package mcp-server - working-directory: packages/mcp-server - run: pnpm pack --pack-destination dist - - - 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..f982ba787 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,14 +3,6 @@ name: Release on: workflow_dispatch: inputs: - product: - description: Product to release - required: true - type: choice - default: mcp - options: - - cli - - mcp bump: description: Version bump type required: true @@ -54,38 +46,12 @@ jobs: with: run_install: false - - name: Resolve release workspace - id: release - env: - PRODUCT: ${{ inputs.product }} - run: | - 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 - ;; - esac - - { - echo "workspace=$workspace" - echo "package_path=$package_path" - } >> "$GITHUB_OUTPUT" - - name: Calculate version id: version env: BUMP: ${{ inputs.bump }} - PACKAGE_PATH: ${{ steps.release.outputs.package_path }} run: | - CURRENT=$(node -p "require('./$PACKAGE_PATH/package.json').version") + CURRENT=$(node -p "require('./packages/cli/package.json').version") NEW=$(npm exec --yes --package=semver@7.7.2 -- semver -i "$BUMP" "$CURRENT") echo "version=$NEW" >> "$GITHUB_OUTPUT" @@ -94,7 +60,8 @@ jobs: env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: - workspace: ${{ steps.release.outputs.workspace }} + # Also publishes @sentry/mcp-server at the same version. + workspace: packages/cli version: ${{ steps.version.outputs.version }} force: ${{ inputs.force && 'true' || '' }} # GITHUB_TOKEN above is the release bot token for git/GitHub ops. diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index 9a4adbe56..d6e07bfe4 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -4,10 +4,10 @@ How the local stdio MCP server ships. ## Overview -The stdio MCP server ships as `@sentry/mcp-server`, released from the -`packages/mcp-server` Craft workspace. Its source lives in `packages/cli` so it -shares the CLI's auth, which also exposes it as `sentry mcp` in each CLI -release. +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: @@ -36,16 +36,28 @@ The package is a single file with no runtime dependencies. past its size budget. `pnpm --filter @sentry/mcp-server run bundle` builds it and `run test:bundle` -smoke-tests the result. The `packages/mcp-server` Craft workspace releases it -from `release/mcp` branches; `.github/workflows/merge-jobs.yml` bundles, tests, -and packs it. Each release bundles the CLI source at that commit, so it does -not wait for a CLI release. +smoke-tests the result. + +## Release + +The `packages/cli` Craft workspace releases both packages through the **Release** +workflow. + +- `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 -`@sentry/mcp-server` releases (not `cli@` tags) using GitHub Actions OIDC; no -registry secret is needed. It reads the manifest from `main` and sets both +`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 @@ -53,10 +65,11 @@ endpoint. npm verifies ownership through `mcpName` in 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: +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/toolkit --ref main -f version=0.42.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 @@ -80,7 +93,7 @@ Users run the server through npx in their MCP client configuration: Sign in once with `npx @sentry/mcp-server@latest auth login` (or `sentry auth login`), or add `"env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." }`. -Pin a version with `@sentry/mcp-server@0.42.0`. +Pin a version with `@sentry/mcp-server@0.48.0`. ## Testing Before a Release 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-mcp-server.ts b/packages/cli/script/bundle-mcp-server.ts index 1f3e4e42c..a91729a78 100644 --- a/packages/cli/script/bundle-mcp-server.ts +++ b/packages/cli/script/bundle-mcp-server.ts @@ -81,6 +81,15 @@ 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 +// `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 }); 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/mcp-server/package.json b/packages/mcp-server/package.json index bde7a3445..df7935b66 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,7 +1,7 @@ { "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", From 9995e37ce32997ad9971cb588ca873fb1ddb0766 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:57:39 +0000 Subject: [PATCH 18/29] ref(mcp-server): Own the bundle script, stub CLI-only modules, report to the CLI's project Review follow-ups from BYK on #1731: - Move the bundle script into packages/mcp-server (script/bundle.ts). It still bundles the CLI's src/mcp-server.ts entry. - Resolve Ink, React, and the SQLite WASM fallback to empty stub modules instead of marking them external, so the bundle never require()s a package the tarball does not ship. A bundle test now fails on any non-builtin require (the old output had a dangling node-sqlite3-wasm one). - Without --sentry-dsn/SENTRY_DSN, stdio telemetry reports to the CLI's project, honors the CLI's opt-outs, uses the CLI's credential redaction, and tags app.server.distribution (@sentry/mcp-server vs sentry mcp). - Log the project-context cache and default-URL failures at debug level instead of a grandfathered silent catch. - The test client's --stdio-bundle runs the built package instead of CLI source; docs show testing the bundle and the packed tarball. - Docs: lead auth with `npx @sentry/mcp-server auth login`, clarify that mcp-core is private and bundled. --- docs/architecture/overview.md | 4 +- docs/operations/monitoring.md | 6 +- docs/operations/stdio-auth.md | 26 +++--- docs/releases/stdio.md | 30 +++++-- docs/testing/stdio.md | 23 +++++- packages/cli/AGENTS.md | 2 +- packages/cli/package.json | 1 - packages/cli/src/lib/login-command.ts | 2 +- packages/cli/src/lib/mcp/server.ts | 32 +++++++- packages/cli/src/lib/project-context.ts | 14 ++-- packages/cli/src/mcp-server.ts | 2 +- packages/cli/test/lib/mcp/telemetry.test.ts | 82 +++++++++++++++++++ packages/mcp-server/package.json | 6 +- .../script/bundle.ts} | 58 ++++++++----- packages/mcp-server/test/bundle.test.mjs | 15 ++++ packages/mcp-test-client/README.md | 4 + packages/mcp-test-client/src/index.ts | 5 ++ .../mcp-test-client/src/mcp-test-client.ts | 30 +++---- .../mcp-test-client/src/transport.test.ts | 21 +++++ packages/mcp-test-client/src/transport.ts | 19 +++++ packages/mcp-test-client/src/types.ts | 2 + pnpm-lock.yaml | 9 +- 22 files changed, 312 insertions(+), 81 deletions(-) create mode 100644 packages/cli/test/lib/mcp/telemetry.test.ts rename packages/{cli/script/bundle-mcp-server.ts => mcp-server/script/bundle.ts} (80%) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 211c3db42..c8fa249a7 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -53,7 +53,9 @@ 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. +**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. ### packages/cli (`sentry mcp`) diff --git a/docs/operations/monitoring.md b/docs/operations/monitoring.md index fe9c08575..547e16633 100644 --- a/docs/operations/monitoring.md +++ b/docs/operations/monitoring.md @@ -8,7 +8,10 @@ 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 @@ -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 diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index 92f905241..f7cedb070 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -2,10 +2,10 @@ How the stdio MCP server (`@sentry/mcp-server`) authenticates with Sentry. -The server has no auth flow or token cache of its own. It reuses the Sentry -CLI's credential store: `npx -y @sentry/mcp-server auth login` runs the CLI's -device flow, and a login from `sentry auth login` works too. The same server -also runs as `sentry mcp` in the CLI. +Sign in once with `npx -y @sentry/mcp-server auth login`. The package ships +its own OAuth device flow and stores the session in the Sentry CLI's +credential store, so the package and the CLI (which runs the same server as +`sentry mcp`) share one login. ## Credential Sources @@ -26,13 +26,11 @@ In order of precedence: rotation without a restart. When no credential is available and stdin is a TTY, the server starts the -interactive login before serving. MCP clients launch the server over -pipes, so in that case it exits with an error that names the login command: -`npx -y @sentry/mcp-server auth login` from `@sentry/mcp-server`, and -`sentry auth login` from `sentry mcp`. Other auth hints follow the same rule -(`packages/cli/src/lib/login-command.ts`), except that confirming a self-hosted host with -`--url` always names `npx -y sentry auth login --url`, because the -standalone `auth login` takes no flags. +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 (`packages/cli/src/lib/login-command.ts`). The one +exception is confirming a self-hosted host with `--url`: the package's +`auth login` takes no flags yet, so that hint names the CLI. For self-hosted Sentry, set the host with `--host`/`--url` or `SENTRY_HOST`/`SENTRY_URL`. Only the environment variables also pick the stored @@ -65,9 +63,9 @@ MCP servers, for example one per host via `SENTRY_HOST`, or one with a static Older releases had their own device code flow, bundled client ID, and token cache (`~/.sentry/mcp.json`, `SENTRY_MCP_AUTH_CACHE`). Current releases do not read that cache. Run `npx -y @sentry/mcp-server auth login` once, or keep -passing `--access-token` / `SENTRY_ACCESS_TOKEN`. `auth logout` and -`auth status` are not part of the package; use `npx -y sentry auth logout` or -`npx -y sentry auth status`. +passing `--access-token` / `SENTRY_ACCESS_TOKEN`. The package does not have +`auth logout` or `auth status` yet; the CLI's commands work on the same +session. ## Key Files diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index d6e07bfe4..fada4ffdd 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -22,7 +22,7 @@ accepts these credentials: ## Bundle The package is a single file with no runtime dependencies. -`packages/cli/script/bundle-mcp-server.ts` bundles +`packages/mcp-server/script/bundle.ts` bundles the CLI's `packages/cli/src/mcp-server.ts` into `dist/sentry-mcp.cjs`: - Full flags, environment variables, tools, and self-hosted support, using the @@ -33,7 +33,9 @@ The package is a single file with no runtime dependencies. completions, terminal images, the Ink UI, and debug-file processing. The SQLite WASM fallback is left out too, so the package needs Node.js 22.15+. The bundle script fails if any of these reach the output or the file grows - past its size budget. + past its size budget. Packages that only those code paths import (Ink, + React, the SQLite WASM fallback) 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. @@ -91,24 +93,34 @@ Users run the server through npx in their MCP client configuration: } ``` -Sign in once with `npx @sentry/mcp-server@latest auth login` (or -`sentry auth login`), or add `"env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." }`. +Sign in once with `npx @sentry/mcp-server@latest auth login`, or add +`"env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." }`. Pin a version with `@sentry/mcp-server@0.48.0`. ## Testing Before a Release +Test the package itself, not just the CLI source it is built from: + ```bash -# Run the server from source -pnpm --silent --dir packages/cli run cli mcp --help +# Build and smoke-test the bundle +pnpm --filter @sentry/mcp-server run bundle +pnpm --filter @sentry/mcp-server run test:bundle + +# Exercise the built bundle with the test client +pnpm -w run cli --transport stdio --stdio-bundle "who am I?" -# Exercise it with the test client -pnpm -w run cli --transport stdio "who am I?" +# 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 ``` +While iterating, `pnpm -w run cli --transport stdio` (without +`--stdio-bundle`) runs `sentry mcp` from CLI source, which needs no build. + ## References - Command entry: `packages/cli/src/lib/mcp.ts` - Host (flags, env, telemetry): `packages/cli/src/lib/mcp/` - stdio transport: `packages/mcp-core/src/transports/stdio.ts` - `@sentry/mcp-server` entry: `packages/cli/src/mcp-server.ts` -- `@sentry/mcp-server` bundle: `packages/cli/script/bundle-mcp-server.ts` +- `@sentry/mcp-server` bundle: `packages/mcp-server/script/bundle.ts` diff --git a/docs/testing/stdio.md b/docs/testing/stdio.md index 5cba855b2..75aa5fe10 100644 --- a/docs/testing/stdio.md +++ b/docs/testing/stdio.md @@ -61,7 +61,21 @@ pnpm --silent --dir packages/cli run cli mcp --access-token=YOUR_TOKEN `--silent` keeps pnpm's banner off stdout, which carries JSON-RPC frames. -### 3. Run the Published Package +### 3. Run the Built Package + +Source runs skip the bundling step, so check the bundle that ships as +`@sentry/mcp-server` before a release: + +```bash +pnpm --filter @sentry/mcp-server run bundle +node packages/mcp-server/dist/sentry-mcp.cjs --help +node packages/mcp-server/dist/sentry-mcp.cjs --access-token=YOUR_TOKEN + +# Or drive it with the test client +pnpm -w run cli --transport stdio --stdio-bundle --list-tools +``` + +### 4. Run the Published Package ```bash npx @sentry/mcp-server@latest @@ -440,9 +454,10 @@ OPENAI_API_KEY=your-key pnpm --silent --dir packages/cli run cli mcp **Cause:** No CLI login and no static token. -**Solution:** Run `sentry auth login` (`pnpm --filter sentry run cli auth login` -from source), or pass `--access-token` / `SENTRY_ACCESS_TOKEN`. MCP clients do -not give the server a TTY, so log in from a terminal first. +**Solution:** Run `npx @sentry/mcp-server@latest auth login` (from source: +`pnpm --silent --dir packages/cli run cli auth login`), or pass +`--access-token` / `SENTRY_ACCESS_TOKEN`. MCP clients do not give the server a +TTY, so log in from a terminal first. ### "AI-powered search tools unavailable" diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index cd2319c21..bc56688d9 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -114,7 +114,7 @@ Top-level layout: - **`src/bin.ts`** — entry point; **`src/app.ts`** — Stricli application setup; **`src/context.ts`** — dependency-injection context. - **`src/mcp-server.ts`** — entry point for the standalone `@sentry/mcp-server` - bundle (`script/bundle-mcp-server.ts`). It must not import anything that + 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`, diff --git a/packages/cli/package.json b/packages/cli/package.json index 15bbc3c18..372c82c7f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -61,7 +61,6 @@ "build": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", - "bundle:mcp-server": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle-mcp-server.ts", "typecheck": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit", "lint": "pnpm -w exec oxlint --report-unused-disable-directives packages/cli && pnpm -w exec oxfmt --check packages/cli", "lint:fix": "pnpm -w exec oxlint --fix packages/cli && pnpm -w exec oxfmt --write packages/cli", diff --git a/packages/cli/src/lib/login-command.ts b/packages/cli/src/lib/login-command.ts index d1b149b29..b8c325369 100644 --- a/packages/cli/src/lib/login-command.ts +++ b/packages/cli/src/lib/login-command.ts @@ -4,7 +4,7 @@ * sent to a CLI they may not have installed. */ -/** Set by `script/bundle-mcp-server.ts` to the `@sentry/mcp-server` version. */ +/** Set by `packages/mcp-server/script/bundle.ts` to the `@sentry/mcp-server` version. */ declare const SENTRY_MCP_SERVER_VERSION: string | undefined; /** The `@sentry/mcp-server` version when running as that bundle. */ diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index e1345f497..0b1f684be 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -26,9 +26,14 @@ import { consoleLoggingIntegration, flush, init, + makeNodeTransport, vercelAIIntegration, zodErrorsIntegration, } from "@sentry/node"; +import { CLI_VERSION, SENTRY_CLI_DSN } from "../constants.js"; +import { MCP_SERVER_VERSION } from "../login-command.js"; +import { isTelemetryEnabled } from "../telemetry.js"; +import { redactTelemetryEnvelope } from "../telemetry/credential-redaction.js"; import { mergeMcpArgs, parseMcpArgv, parseMcpEnv } from "./args.js"; import { finalizeMcpConfig, type McpServerConfig } from "./config.js"; import { buildMcpUsage, buildMcpVersion } from "./usage.js"; @@ -230,19 +235,32 @@ function configureAgentProvider( console.warn(""); } -function initMcpTelemetry( +/** + * Start MCP telemetry. + * + * @internal Exported for testing + */ +export function initMcpTelemetry( config: McpServerConfig, env: NodeJS.ProcessEnv, ): void { const scopeContext = { "app.server.version": LIB_VERSION, + // Tells `@sentry/mcp-server` installs apart from `sentry mcp`. + "app.server.distribution": MCP_SERVER_VERSION + ? "@sentry/mcp-server" + : "sentry mcp", "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, + dsn: config.sentryDsn ?? SENTRY_CLI_DSN, + enabled: !useCliDsn || isTelemetryEnabled(), tracesSampleRate: 1, beforeSend: sentryBeforeSend, initialScope: { @@ -256,7 +274,15 @@ function initMcpTelemetry( "app.server.mode.experimental": config.experimental, }, }, - release: env.SENTRY_RELEASE, + release: env.SENTRY_RELEASE ?? (useCliDsn ? CLI_VERSION : undefined), + // 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(), diff --git a/packages/cli/src/lib/project-context.ts b/packages/cli/src/lib/project-context.ts index 1276ac65f..4d86e66f9 100644 --- a/packages/cli/src/lib/project-context.ts +++ b/packages/cli/src/lib/project-context.ts @@ -24,6 +24,10 @@ export async function preloadProjectContext(cwd: string): Promise { import("./db/project-root-cache.js"), ]); + // Dynamic import: logger chains into consola + 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. @@ -33,10 +37,7 @@ export async function preloadProjectContext(cwd: string): Promise { reason: result.reason, }); } catch (error) { - const { logger } = await import("./logger.js"); - logger - .withTag("project-context") - .debug("Could not cache the project root", error); + log.debug("Could not cache the project root", error); } // Apply .sentryclirc env shim (token + URL). The URL trust check is @@ -49,7 +50,6 @@ export async function preloadProjectContext(cwd: string): Promise { // Apply persistent URL default (lower priority than env vars and .sentryclirc). const env = getEnv(); if (!(env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim())) { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { const { getDefaultUrl } = await import("./db/defaults.js"); const url = getDefaultUrl(); @@ -58,8 +58,8 @@ export async function preloadProjectContext(cwd: string): Promise { const { markPersistedDefaultUrl } = await import("./constants.js"); markPersistedDefaultUrl(url); } - } catch { - // DB not available — skip + } catch (error) { + log.debug("Could not read the persisted default URL", error); } } } diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts index fc48c475c..c9d41e98d 100644 --- a/packages/cli/src/mcp-server.ts +++ b/packages/cli/src/mcp-server.ts @@ -2,7 +2,7 @@ * Entry point for the standalone `@sentry/mcp-server` bundle. * * Runs the same server as `sentry mcp` without the rest of the CLI, so the - * package stays small. Built by `script/bundle-mcp-server.ts`. + * package stays small. Built by `packages/mcp-server/script/bundle.ts`. */ // Must stay the first import: later imports can load node:sqlite, whose 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..505e3b987 --- /dev/null +++ b/packages/cli/test/lib/mcp/telemetry.test.ts @@ -0,0 +1,82 @@ +import type { NodeOptions } from "@sentry/node"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { 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"]); + +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" }, + }); + }); + + 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("tags @sentry/mcp-server installs", async () => { + vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); + vi.resetModules(); + const options = await initOptions(); + expect(options.initialScope).toMatchObject({ + tags: { "app.server.distribution": "@sentry/mcp-server" }, + }); + }); +}); diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index df7935b66..584cdc4a1 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -40,7 +40,11 @@ "dist/sentry-mcp.cjs" ], "scripts": { - "bundle": "pnpm --filter sentry run bundle:mcp-server", + "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": { + "esbuild": "^0.28.1", + "tsx": "catalog:" } } diff --git a/packages/cli/script/bundle-mcp-server.ts b/packages/mcp-server/script/bundle.ts similarity index 80% rename from packages/cli/script/bundle-mcp-server.ts rename to packages/mcp-server/script/bundle.ts index a91729a78..66cb52c06 100644 --- a/packages/cli/script/bundle-mcp-server.ts +++ b/packages/mcp-server/script/bundle.ts @@ -1,6 +1,6 @@ #!/usr/bin/env tsx /** - * Bundle `src/mcp-server.ts` into the standalone `@sentry/mcp-server` package. + * Bundle the CLI's `src/mcp-server.ts` into this package's `dist/sentry-mcp.cjs`. * * The package runs the same server as `sentry mcp` but ships only what the * server needs: the MCP tools, the API client, and the CLI's auth. The build @@ -12,11 +12,11 @@ import { mkdir, readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build, type Metafile, type Plugin } from "esbuild"; -import { requireAliasPlugin } from "./lib/require-alias-plugin.js"; -import { textImportPlugin } from "./text-import-plugin.js"; +import { requireAliasPlugin } from "../../cli/script/lib/require-alias-plugin.js"; +import { textImportPlugin } from "../../cli/script/text-import-plugin.js"; -const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const PACKAGE_ROOT = resolve(CLI_ROOT, "../mcp-server"); +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"); /** @@ -65,6 +65,30 @@ const plainMarkdownPlugin: Plugin = { }, }; +/** + * 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. + */ +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) @@ -82,7 +106,7 @@ const cliPkg = JSON.parse( ) as { version: string }; // Each CLI release publishes this package at the CLI's version, set by -// `script/bump-version.ts`. +// `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`, @@ -118,22 +142,14 @@ const result = await build({ __SENTRY_CHANNEL_INJECTION__: "false", "import.meta.url": "import_meta_url", }, - external: [ - "node:*", - // Only reachable on Node.js < 22.15, which the version check rejects. - "node-sqlite3-wasm", - "@sentry/symbolic/symbolic_bg.wasm", - "ink", - "ink-spinner", - "react", - "react/*", - "react-reconciler", - "react-reconciler/*", - "react-devtools-core", - "yoga-layout", - ], + external: ["node:*"], metafile: true, - plugins: [plainMarkdownPlugin, textImportPlugin, requireAliasPlugin], + plugins: [ + stubModulesPlugin, + plainMarkdownPlugin, + textImportPlugin, + requireAliasPlugin, + ], }); const forbidden = findForbiddenInputs(result.metafile); diff --git a/packages/mcp-server/test/bundle.test.mjs b/packages/mcp-server/test/bundle.test.mjs index 22b54faab..0408376ba 100644 --- a/packages/mcp-server/test/bundle.test.mjs +++ b/packages/mcp-server/test/bundle.test.mjs @@ -4,6 +4,7 @@ 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"; @@ -37,6 +38,20 @@ test("the package has no runtime 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)], []); +}); + test("--version prints the package version", () => { const result = run(["--version"]); assert.equal(result.status, 0, result.stderr); diff --git a/packages/mcp-test-client/README.md b/packages/mcp-test-client/README.md index 29487541e..4c9b21b4e 100644 --- a/packages/mcp-test-client/README.md +++ b/packages/mcp-test-client/README.md @@ -124,6 +124,10 @@ 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 + +# 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 ``` Stdio mode runs the workspace CLI's `sentry mcp`. When no token is provided, 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 de30d700e..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,24 +30,20 @@ export async function connectToMCPServer( }, async (span) => { try { - // Run the workspace CLI's `sentry mcp` from source. - const cliDir = join( - dirname(fileURLToPath(import.meta.url)), - "../../cli", + 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: "pnpm", - args: [ - "--silent", - "--dir", - cliDir, - "run", - "cli", - "mcp", - ...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..1ebc1da6f 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"; @@ -78,3 +79,23 @@ describe("buildStdioServerLaunchConfig", () => { }); }); }); + +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..98c372527 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: { @@ -54,3 +55,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 9e4ba23e0..395a7ee60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -852,7 +852,14 @@ importers: specifier: ^2.8.3 version: 2.8.3 - packages/mcp-server: {} + packages/mcp-server: + devDependencies: + esbuild: + specifier: ^0.28.1 + version: 0.28.2 + tsx: + specifier: 'catalog:' + version: 4.20.3 packages/mcp-server-evals: dependencies: From 83ef1771626d0a9be8927fdc4b5945da6aa7861d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:31:12 +0000 Subject: [PATCH 19/29] fix(mcp): Name the running binary in arg errors and tag its real version Unknown-flag errors now point `sentry-mcp` users at `sentry-mcp --help` instead of `sentry mcp --help`, and `app.server.version` reports the package or CLI release instead of mcp-core's `0.0.0` fallback. --- packages/cli/src/lib/mcp/config.ts | 3 ++- packages/cli/src/lib/mcp/server.ts | 5 ++-- packages/cli/src/lib/mcp/usage.ts | 26 ++++++++++++--------- packages/cli/test/lib/mcp/config.test.ts | 20 +++++++++++++++- packages/cli/test/lib/mcp/telemetry.test.ts | 12 +++++++--- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/lib/mcp/config.ts b/packages/cli/src/lib/mcp/config.ts index 245bd0761..2ff38dd60 100644 --- a/packages/cli/src/lib/mcp/config.ts +++ b/packages/cli/src/lib/mcp/config.ts @@ -20,6 +20,7 @@ import { import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; import { ValidationError } from "../errors.js"; import type { McpMergedArgs } from "./args.js"; +import { MCP_COMMAND } from "./usage.js"; /** Embedded agent providers the MCP server can use. */ export type McpAgentProvider = @@ -165,7 +166,7 @@ function resolveAgentProvider(value?: string): McpAgentProvider | undefined { export function finalizeMcpConfig(input: McpMergedArgs): McpServerConfig { if (input.unknownArgs.length > 0) { throw new ValidationError( - `Invalid argument(s): ${input.unknownArgs.join(", ")}\nRun \`sentry mcp --help\` for usage.`, + `Invalid argument(s): ${input.unknownArgs.join(", ")}\nRun \`${MCP_COMMAND} --help\` for usage.`, ); } if (input.insecureHttp && input.url) { diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 0b1f684be..4ef9c66c2 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -20,7 +20,6 @@ import { ALL_SKILLS } from "@sentry/mcp-core/skills"; import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; import { startStdio } from "@sentry/mcp-core/transports/stdio"; import type { ServerContext } from "@sentry/mcp-core/types"; -import { LIB_VERSION } from "@sentry/mcp-core/version"; import { captureException, consoleLoggingIntegration, @@ -36,7 +35,7 @@ import { isTelemetryEnabled } from "../telemetry.js"; import { redactTelemetryEnvelope } from "../telemetry/credential-redaction.js"; import { mergeMcpArgs, parseMcpArgv, parseMcpEnv } from "./args.js"; import { finalizeMcpConfig, type McpServerConfig } from "./config.js"; -import { buildMcpUsage, buildMcpVersion } from "./usage.js"; +import { buildMcpUsage, buildMcpVersion, MCP_VERSION } from "./usage.js"; /** What a `sentry mcp` invocation asks for. */ export type McpInvocation = @@ -245,7 +244,7 @@ export function initMcpTelemetry( env: NodeJS.ProcessEnv, ): void { const scopeContext = { - "app.server.version": LIB_VERSION, + "app.server.version": MCP_VERSION, // Tells `@sentry/mcp-server` installs apart from `sentry mcp`. "app.server.distribution": MCP_SERVER_VERSION ? "@sentry/mcp-server" diff --git a/packages/cli/src/lib/mcp/usage.ts b/packages/cli/src/lib/mcp/usage.ts index 4bf9afe46..86902c943 100644 --- a/packages/cli/src/lib/mcp/usage.ts +++ b/packages/cli/src/lib/mcp/usage.ts @@ -4,16 +4,20 @@ import type { Skill } from "@sentry/mcp-core/skills"; import { CLI_VERSION } from "../constants.js"; import { LOGIN_COMMAND, MCP_SERVER_VERSION } from "../login-command.js"; -const COMMAND = MCP_SERVER_VERSION ? "sentry-mcp" : "sentry mcp"; +/** The command that launched this server, for help and error text. */ +export const MCP_COMMAND = MCP_SERVER_VERSION ? "sentry-mcp" : "sentry mcp"; + +/** The release that is running: the package version or the CLI's. */ +export const MCP_VERSION = MCP_SERVER_VERSION ?? CLI_VERSION; /** Build the `--version` output. */ export function buildMcpVersion(): string { - return `${COMMAND} ${MCP_SERVER_VERSION ?? CLI_VERSION}`; + return `${MCP_COMMAND} ${MCP_VERSION}`; } /** Build the `--help` text. */ export function buildMcpUsage(allSkills: ReadonlyArray): string { - return `Usage: ${COMMAND} [--access-token=] [--host=] [--insecure-http] + return `Usage: ${MCP_COMMAND} [--access-token=] [--host=] [--insecure-http] Authentication: Uses the active Sentry CLI session by default. Authenticate with \`${LOGIN_COMMAND}\`. @@ -66,12 +70,12 @@ Environment variables: MCP_DISABLE_SKILLS Disable specific skills (comma-separated) Examples: - ${COMMAND} - ${COMMAND} --all-skills - ${COMMAND} --skills=inspect,triage - ${COMMAND} --host=sentry.example.com - ${COMMAND} --host=sentry.internal:9000 --insecure-http - ${COMMAND} --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer - ${COMMAND} --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ - ${COMMAND} --agent-provider=anthropic`; + ${MCP_COMMAND} + ${MCP_COMMAND} --all-skills + ${MCP_COMMAND} --skills=inspect,triage + ${MCP_COMMAND} --host=sentry.example.com + ${MCP_COMMAND} --host=sentry.internal:9000 --insecure-http + ${MCP_COMMAND} --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer + ${MCP_COMMAND} --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ + ${MCP_COMMAND} --agent-provider=anthropic`; } diff --git a/packages/cli/test/lib/mcp/config.test.ts b/packages/cli/test/lib/mcp/config.test.ts index c23d17ad7..5ff1c93ad 100644 --- a/packages/cli/test/lib/mcp/config.test.ts +++ b/packages/cli/test/lib/mcp/config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } 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"; @@ -379,6 +379,24 @@ describe("finalizeMcpConfig", () => { ); }); + it("points @sentry/mcp-server installs at their own --help", async () => { + vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); + vi.resetModules(); + try { + const { finalizeMcpConfig: finalizeBundled } = + await import("../../../src/lib/mcp/config.js"); + expect(() => + finalizeBundled({ + agentProviderFromFlag: false, + unknownArgs: ["--bogus"], + }), + ).toThrow(/Run `sentry-mcp --help`/); + } finally { + vi.unstubAllGlobals(); + vi.resetModules(); + } + }); + it("reports invalid input as a CLI validation error", () => { expect(() => finalize({ url: "http://bad" })).toThrow(ValidationError); expect(() => finalize({ skills: "nope" })).toThrow(ValidationError); diff --git a/packages/cli/test/lib/mcp/telemetry.test.ts b/packages/cli/test/lib/mcp/telemetry.test.ts index 505e3b987..fcbb13559 100644 --- a/packages/cli/test/lib/mcp/telemetry.test.ts +++ b/packages/cli/test/lib/mcp/telemetry.test.ts @@ -1,6 +1,6 @@ import type { NodeOptions } from "@sentry/node"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { SENTRY_CLI_DSN } from "../../../src/lib/constants.js"; +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"; @@ -52,7 +52,10 @@ describe("initMcpTelemetry", () => { expect(options.dsn).toBe(SENTRY_CLI_DSN); expect(options.enabled).toBe(true); expect(options.initialScope).toMatchObject({ - tags: { "app.server.distribution": "sentry mcp" }, + tags: { + "app.server.distribution": "sentry mcp", + "app.server.version": CLI_VERSION, + }, }); }); @@ -76,7 +79,10 @@ describe("initMcpTelemetry", () => { vi.resetModules(); const options = await initOptions(); expect(options.initialScope).toMatchObject({ - tags: { "app.server.distribution": "@sentry/mcp-server" }, + tags: { + "app.server.distribution": "@sentry/mcp-server", + "app.server.version": "1.2.3", + }, }); }); }); From f5c4c31489f5cc811089ad4af6c029291466c8d1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:42:29 +0000 Subject: [PATCH 20/29] fix(mcp): Always report the running release and keep the release workspace input MCP telemetry no longer takes its release from `SENTRY_RELEASE`, so the reported release is always the CLI or @sentry/mcp-server version that runs. The Release workflow keeps its `product` input and workspace mapping instead of hard-coding `packages/cli`, so adding a Craft workspace later does not silently release the CLI. --- .github/workflows/release.yml | 36 +++++++++++++++++++-- docs/releases/stdio.md | 4 +-- packages/cli/src/lib/mcp/server.ts | 12 +++---- packages/cli/test/lib/mcp/telemetry.test.ts | 14 ++++++-- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f982ba787..dbea66e23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,13 @@ name: Release on: workflow_dispatch: inputs: + product: + description: Product to release + required: true + type: choice + default: cli + options: + - cli bump: description: Version bump type required: true @@ -46,12 +53,36 @@ jobs: with: run_install: false + - name: Resolve release workspace + id: release + 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 + ;; + *) + echo "::error::Unsupported product: $PRODUCT" + exit 1 + ;; + esac + + { + echo "workspace=$workspace" + echo "package_path=$package_path" + } >> "$GITHUB_OUTPUT" + - name: Calculate version id: version env: BUMP: ${{ inputs.bump }} + PACKAGE_PATH: ${{ steps.release.outputs.package_path }} run: | - CURRENT=$(node -p "require('./packages/cli/package.json').version") + CURRENT=$(node -p "require('./$PACKAGE_PATH/package.json').version") NEW=$(npm exec --yes --package=semver@7.7.2 -- semver -i "$BUMP" "$CURRENT") echo "version=$NEW" >> "$GITHUB_OUTPUT" @@ -60,8 +91,7 @@ jobs: env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: - # Also publishes @sentry/mcp-server at the same version. - workspace: packages/cli + workspace: ${{ steps.release.outputs.workspace }} version: ${{ steps.version.outputs.version }} force: ${{ inputs.force && 'true' || '' }} # GITHUB_TOKEN above is the release bot token for git/GitHub ops. diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index fada4ffdd..d55a0f2d9 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -42,8 +42,8 @@ smoke-tests the result. ## Release -The `packages/cli` Craft workspace releases both packages through the **Release** -workflow. +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. diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 4ef9c66c2..57bf08f71 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -29,7 +29,7 @@ import { vercelAIIntegration, zodErrorsIntegration, } from "@sentry/node"; -import { CLI_VERSION, SENTRY_CLI_DSN } from "../constants.js"; +import { SENTRY_CLI_DSN } from "../constants.js"; import { MCP_SERVER_VERSION } from "../login-command.js"; import { isTelemetryEnabled } from "../telemetry.js"; import { redactTelemetryEnvelope } from "../telemetry/credential-redaction.js"; @@ -239,10 +239,7 @@ function configureAgentProvider( * * @internal Exported for testing */ -export function initMcpTelemetry( - config: McpServerConfig, - env: NodeJS.ProcessEnv, -): void { +export function initMcpTelemetry(config: McpServerConfig): void { const scopeContext = { "app.server.version": MCP_VERSION, // Tells `@sentry/mcp-server` installs apart from `sentry mcp`. @@ -273,7 +270,8 @@ export function initMcpTelemetry( "app.server.mode.experimental": config.experimental, }, }, - release: env.SENTRY_RELEASE ?? (useCliDsn ? CLI_VERSION : undefined), + // Always the running release; an env override would misreport it. + release: MCP_VERSION, // Same credential redaction as the CLI's own telemetry. transport: (transportOptions) => { const transport = makeNodeTransport(transportOptions); @@ -357,7 +355,7 @@ export function serveMcpStdio( options: McpServeOptions, ): void { configureAgentProvider(config, options.env); - initMcpTelemetry(config, options.env); + initMcpTelemetry(config); if (config.experimental) { console.warn( diff --git a/packages/cli/test/lib/mcp/telemetry.test.ts b/packages/cli/test/lib/mcp/telemetry.test.ts index fcbb13559..1cc0ada2d 100644 --- a/packages/cli/test/lib/mcp/telemetry.test.ts +++ b/packages/cli/test/lib/mcp/telemetry.test.ts @@ -14,7 +14,7 @@ vi.mock("@sentry/node", async (importOriginal) => ({ })); useTestConfigDir("mcp-telemetry-"); -useEnvSandbox(["SENTRY_CLI_NO_TELEMETRY", "DO_NOT_TRACK"]); +useEnvSandbox(["SENTRY_CLI_NO_TELEMETRY", "DO_NOT_TRACK", "SENTRY_RELEASE"]); const config: McpServerConfig = { sentryHost: "sentry.io", @@ -29,7 +29,7 @@ async function initOptions( overrides: Partial = {}, ): Promise { const { initMcpTelemetry } = await import("../../../src/lib/mcp/server.js"); - initMcpTelemetry({ ...config, ...overrides }, {}); + initMcpTelemetry({ ...config, ...overrides }); const options = sentry.init.mock.lastCall?.[0]; if (!options) { throw new Error("Sentry.init was not called"); @@ -57,6 +57,7 @@ describe("initMcpTelemetry", () => { "app.server.version": CLI_VERSION, }, }); + expect(options.release).toBe(CLI_VERSION); }); test("honors the CLI's telemetry opt-out", async () => { @@ -74,6 +75,14 @@ describe("initMcpTelemetry", () => { 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 () => { vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); vi.resetModules(); @@ -84,5 +93,6 @@ describe("initMcpTelemetry", () => { "app.server.version": "1.2.3", }, }); + expect(options.release).toBe("1.2.3"); }); }); From 53fc92fe075587626d1e0b5352708ba032082667 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:18:26 +0000 Subject: [PATCH 21/29] fix(mcp): Serialize manual MCP Registry retries with the release run A dispatch for `0.48.0` and the `cli@0.48.0` release run used different concurrency groups, so both could see a missing registry version and race to publish it. Both now key on the `cli@` tag. --- .github/workflows/mcp-registry.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mcp-registry.yml b/.github/workflows/mcp-registry.yml index f8edc485d..59e7b1fc1 100644 --- a/.github/workflows/mcp-registry.yml +++ b/.github/workflows/mcp-registry.yml @@ -17,8 +17,10 @@ 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: From f9ba7f93e4080997a7a54fbbc11772df7305127d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:27:23 +0000 Subject: [PATCH 22/29] perf(cli): Bundle only Zod's English locale Zod 4's core, classic, and mini entry points re-export locales/index.js, which pulls every locale into the bundle. Nothing we bundle switches the locale, so a shared esbuild plugin now resolves that index to an en-only module. Error messages are unchanged and z.locales.en still works. The npm bundle and the standalone binary use the plugin. It lives in script/lib so the @sentry/mcp-server bundle can reuse it. Part of #1761 Co-Authored-By: BYK --- packages/cli/script/build.ts | 2 + packages/cli/script/bundle.ts | 2 + packages/cli/script/lib/zod-locales-plugin.ts | 56 +++++++++ .../test/script/zod-locales-plugin.test.ts | 119 ++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 packages/cli/script/lib/zod-locales-plugin.ts create mode 100644 packages/cli/test/script/zod-locales-plugin.test.ts diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index e81731d7a..d72433ea2 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -37,6 +37,7 @@ import { gzip } from "node:zlib"; 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 { textImportPlugin } from "./text-import-plugin.js"; const gzipAsync = promisify(gzip); @@ -160,6 +161,7 @@ async function bundleJs(): Promise { }, plugins: [ textImportPlugin, + zodEnglishLocalesPlugin, // 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/bundle.ts b/packages/cli/script/bundle.ts index d6624cfca..fff32d363 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -6,6 +6,7 @@ import { build, type Plugin } from "esbuild"; 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 { buildCoreDeclarations, SDK_TYPES_PATH } from "./sdk-declarations.js"; import { textImportPlugin } from "./text-import-plugin.js"; @@ -253,6 +254,7 @@ const zlibNamespaceShimPlugin: Plugin = { const plugins: Plugin[] = [ sentrySourcemapPlugin, + zodEnglishLocalesPlugin, textImportPlugin, requireAliasPlugin, ]; diff --git a/packages/cli/script/lib/zod-locales-plugin.ts b/packages/cli/script/lib/zod-locales-plugin.ts new file mode 100644 index 000000000..21ee6ba15 --- /dev/null +++ b/packages/cli/script/lib/zod-locales-plugin.ts @@ -0,0 +1,56 @@ +/** + * esbuild plugin that bundles only Zod's English error messages. + * + * Zod 4's `core`, `classic`, and `mini` entry points all re-export + * `locales/index.js`, which statically imports every locale (about 260 KB + * minified). Zod only uses English unless code calls + * `z.config(z.locales.xx())`, and nothing we bundle does. This plugin swaps + * that index for one that exports only `en`, so `z.locales.en` still works + * and error messages are unchanged. `classic/schemas.js` imports `en.js` + * directly to set the default locale, so that path is not touched. + * + * Shared by the CLI bundles (`script/bundle.ts`, `script/build.ts`), which + * inline `@sentry/mcp-core` for `sentry mcp`. + */ + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { Plugin } from "esbuild"; + +const ZOD_LOCALES_NS = "zod-en-locales"; +const LOCALES_INDEX_FILTER = /[\\/]locales[\\/]index\.c?js$/; +const ANY_FILTER = /.*/; +/** Matches a file inside an installed `zod` package (npm or pnpm layout). */ +const ZOD_PACKAGE_RE = /[\\/]node_modules[\\/]zod[\\/]/; + +export const zodEnglishLocalesPlugin: Plugin = { + name: "zod-english-locales", + setup(build) { + build.onResolve({ filter: LOCALES_INDEX_FILTER }, (args) => { + if (!ZOD_PACKAGE_RE.test(`${args.resolveDir}/`)) { + return; + } + // `zod/locales/index.js` re-exports `../v4/locales/index.js`, so both + // resolve here. Keep the importer's module format: `.cjs` for require. + const indexPath = join(args.resolveDir, args.path); + const enPath = join( + dirname(indexPath), + indexPath.endsWith(".cjs") ? "en.cjs" : "en.js", + ); + if (!existsSync(enPath)) { + // Fail loudly if Zod changes its layout, rather than shipping a + // locales namespace without English. + return { + errors: [{ text: `Zod English locale not found at ${enPath}` }], + }; + } + return { path: enPath, namespace: ZOD_LOCALES_NS }; + }); + + build.onLoad({ filter: ANY_FILTER, namespace: ZOD_LOCALES_NS }, (args) => ({ + contents: `export { default as en } from ${JSON.stringify(args.path)};`, + resolveDir: dirname(args.path), + loader: "js", + })); + }, +}; diff --git a/packages/cli/test/script/zod-locales-plugin.test.ts b/packages/cli/test/script/zod-locales-plugin.test.ts new file mode 100644 index 000000000..ab61757f2 --- /dev/null +++ b/packages/cli/test/script/zod-locales-plugin.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for the esbuild plugin that bundles only Zod's English locale. + * + * Builds a small entry against the real `zod` package (resolved from + * `@sentry/mcp-core`, which depends on it) with and without the plugin, then + * runs both bundles and compares their error messages. + */ + +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; +import { build } from "esbuild"; +import { describe, expect, test } from "vitest"; +import { zodEnglishLocalesPlugin } from "../../script/lib/zod-locales-plugin.js"; + +const MCP_CORE_DIR = join( + dirname(fileURLToPath(import.meta.url)), + "../../../mcp-core", +); + +/** `import` resolves Zod's ESM build and `require` its CJS build. */ +const IMPORTS = { + esm: `import { z } from "zod";`, + cjs: `const { z } = require("zod");`, +}; + +const BODY = ` +const issues = [ + z.string().safeParse(1), + z.object({ n: z.number().min(5) }).safeParse({ n: 1 }), + z.enum(["a", "b"]).safeParse("c"), +].flatMap((r) => r.error.issues.map((i) => i.message)); +module.exports = { issues, locales: Object.keys(z.locales) }; +`; + +type BundleResult = { + bytes: number; + issues: string[]; + locales: string[]; +}; + +async function bundleZod( + format: "cjs" | "esm", + withPlugin: boolean, +): Promise { + const result = await build({ + stdin: { + contents: IMPORTS[format] + BODY, + resolveDir: MCP_CORE_DIR, + loader: "js", + }, + bundle: true, + write: false, + platform: "node", + format: "cjs", + logLevel: "silent", + plugins: withPlugin ? [zodEnglishLocalesPlugin] : [], + }); + const code = result.outputFiles[0]?.text ?? ""; + const sandbox = { + module: { exports: {} }, + require: createRequire(import.meta.url), + }; + runInNewContext(code, sandbox); + const { issues, locales } = sandbox.module.exports as Omit< + BundleResult, + "bytes" + >; + return { bytes: code.length, issues, locales }; +} + +describe("zodEnglishLocalesPlugin", () => { + test.each(["esm", "cjs"] as const)( + "keeps English messages and drops other locales (%s)", + async (format) => { + const full = await bundleZod(format, false); + const english = await bundleZod(format, true); + + expect(english.issues).toEqual(full.issues); + expect(english.issues[0]).toBe( + "Invalid input: expected string, received number", + ); + expect(full.locales).toContain("fr"); + expect(english.locales).toEqual(["en"]); + // All other locales together are well over 100 KB unminified. + expect(full.bytes - english.bytes).toBeGreaterThan(100_000); + }, + ); + + test("leaves non-Zod locales/index.js imports alone", async () => { + const result = await build({ + stdin: { + contents: `import { en } from "./locales/index.js"; module.exports = en;`, + resolveDir: "/nonexistent", + loader: "js", + }, + bundle: true, + write: false, + logLevel: "silent", + plugins: [ + zodEnglishLocalesPlugin, + { + name: "fake-locales", + setup(b) { + b.onResolve({ filter: /locales/ }, () => ({ + path: "fake", + namespace: "fake", + })); + b.onLoad({ filter: /.*/, namespace: "fake" }, () => ({ + contents: `export const en = "fake";`, + })); + }, + }, + ], + }); + expect(result.outputFiles[0]?.text).toContain('"fake"'); + }); +}); From d78353380e186be28939c55c4222e3e282917cae Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:37:35 +0000 Subject: [PATCH 23/29] perf(cli): Load syntax highlighting lazily and allow compiling it out `markdown.ts` imported `cli-highlight` at the top level, so every command that reaches `buildCommand` initialized all of highlight.js and parse5 (~1.2 MB of the npm bundle), even when it never renders a code block. Move highlighting into `formatters/highlight.ts`, the only importer of `cli-highlight`. It requires the package on the first code block through the existing `_require` + `REQUIRE_ALIAS_FILTER` pattern, so the CLI bundle keeps it but only runs it when needed. Bundles that don't need colored code blocks, like the MCP server, can set `__SENTRY_CLI_SYNTAX_HIGHLIGHT__` to `false`; esbuild then drops the whole stack. Code blocks fall back to the existing uniform yellow. Co-Authored-By: BYK --- packages/cli/script/build.ts | 2 +- packages/cli/script/bundle.ts | 3 +- packages/cli/src/lib/formatters/highlight.ts | 50 ++++++++++++++ packages/cli/src/lib/formatters/markdown.ts | 15 +---- packages/cli/src/lib/platform-grid.ts | 2 +- .../cli/test/lib/formatters/highlight.test.ts | 52 ++++++++++++++ .../script/syntax-highlight-bundle.test.ts | 67 +++++++++++++++++++ 7 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/lib/formatters/highlight.ts create mode 100644 packages/cli/test/lib/formatters/highlight.test.ts create mode 100644 packages/cli/test/script/syntax-highlight-bundle.test.ts diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index e81731d7a..d7a71c0de 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -50,7 +50,7 @@ const NODE_VERSION = "lts"; /** Files whose lazy relative imports must be included in the native bundle. */ const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema)|formatters[\\/]json|list-command|telemetry)\.ts$/; + /(?:db[\\/](?:index|schema)|formatters[\\/](?:highlight|json)|list-command|telemetry)\.ts$/; const REQUIRE_ALIAS_RE = /\b_require\(/g; /** Build-time constants injected into the binary */ diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index d6624cfca..79a2f163c 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -167,9 +167,10 @@ const sentrySourcemapPlugin: Plugin = { * 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[\\/]json|list-command|telemetry)\.ts$/; + /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/](?:highlight|json)|list-command|telemetry)\.ts$/; const REQUIRE_ALIAS_RE = /\b_require\(/g; const IDENTIFIER_FILTER_RE = /^[A-Za-z_$][\w$]*$/; // Match both the prefixed and bare specifiers — bundled dependencies (e.g. diff --git a/packages/cli/src/lib/formatters/highlight.ts b/packages/cli/src/lib/formatters/highlight.ts new file mode 100644 index 000000000..722664edc --- /dev/null +++ b/packages/cli/src/lib/formatters/highlight.ts @@ -0,0 +1,50 @@ +/** + * Syntax highlighting for fenced code blocks in rendered markdown. + * + * This is the only module that loads `cli-highlight`, which brings all of + * highlight.js and parse5 (~1.2 MB of the npm bundle, and ~50 ms of startup + * when imported eagerly). Every command reaches this module through + * `buildCommand` and the formatters, so the cost is kept off that path in two + * 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. + * - 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 + * never enter the bundle. Code blocks render in the fallback color. + */ + +import { createRequire } from "node:module"; +import chalk from "chalk"; +import { COLORS } from "./colors.js"; + +/** Build-time switch; undefined (enabled) outside bundles that define it. */ +declare const __SENTRY_CLI_SYNTAX_HIGHLIGHT__: boolean | undefined; + +const _require = createRequire(import.meta.url); + +type CliHighlight = typeof import("cli-highlight"); + +let cliHighlight: CliHighlight["highlight"] | undefined; + +/** + * Syntax-highlight a code block. Falls back to uniform yellow if the + * language is unknown, highlighting fails, or highlighting is compiled out. + */ +export function highlightCode(code: string, language?: string): string { + // 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 { + if ( + typeof __SENTRY_CLI_SYNTAX_HIGHLIGHT__ === "undefined" || + __SENTRY_CLI_SYNTAX_HIGHLIGHT__ + ) { + cliHighlight ??= (_require("cli-highlight") as CliHighlight).highlight; + return cliHighlight(code, { language, ignoreIllegals: true }); + } + } catch { + // Fall through to the uniform color below. + } + return chalk.hex(COLORS.yellow)(code); +} diff --git a/packages/cli/src/lib/formatters/markdown.ts b/packages/cli/src/lib/formatters/markdown.ts index 807fd321e..8fdd8240e 100644 --- a/packages/cli/src/lib/formatters/markdown.ts +++ b/packages/cli/src/lib/formatters/markdown.ts @@ -19,10 +19,10 @@ */ import chalk from "chalk"; -import { highlight as cliHighlight } from "cli-highlight"; import { marked, type Token, type Tokens } from "marked"; import stringWidth from "string-width"; import { COLORS, muted, terminalLink } from "./colors.js"; +import { highlightCode } from "./highlight.js"; import { isPlainOutput, stripAnsi } from "./plain-detect.js"; import { type Alignment, renderTextTable } from "./text-table.js"; @@ -232,19 +232,6 @@ function renderHtmlToken(raw: string): string { return ""; } -/** - * Syntax-highlight a code block. Falls back to uniform yellow if the - * language is unknown or highlighting fails. - */ -function highlightCode(code: string, language?: string): string { - // 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 { - return cliHighlight(code, { language, ignoreIllegals: true }); - } catch { - return chalk.hex(COLORS.yellow)(code); - } -} - /** * Flatten a top-level token's inline content. Paragraphs and other block * tokens that wrap inline tokens are unwrapped; bare inline tokens pass diff --git a/packages/cli/src/lib/platform-grid.ts b/packages/cli/src/lib/platform-grid.ts index 177258b5b..b8cae8587 100644 --- a/packages/cli/src/lib/platform-grid.ts +++ b/packages/cli/src/lib/platform-grid.ts @@ -4,7 +4,7 @@ * Deliberately kept out of `platforms.ts`: that module is statically * imported by `complete.ts`'s shell-completion fast path (no Stricli boot, * no heavy deps), and pulling in the markdown/table formatters here would - * drag chalk/marked/cli-highlight/string-width/wrap-ansi into every + * drag chalk/marked/string-width/wrap-ansi into every * `__complete` invocation. Only `project create`'s error message and * `platform list` need this rendering — neither is on the completion path. */ diff --git a/packages/cli/test/lib/formatters/highlight.test.ts b/packages/cli/test/lib/formatters/highlight.test.ts new file mode 100644 index 000000000..e330bf800 --- /dev/null +++ b/packages/cli/test/lib/formatters/highlight.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for the lazy, build-time-removable syntax highlighting boundary. + */ + +import { createRequire } from "node:module"; +import chalk from "chalk"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { COLORS } from "../../../src/lib/formatters/colors.js"; +import { highlightCode } from "../../../src/lib/formatters/highlight.js"; + +const require = createRequire(import.meta.url); +const cliHighlightPath = require.resolve("cli-highlight"); +const { highlight } = require( + cliHighlightPath, +) as typeof import("cli-highlight"); +/** cli-highlight colors through its own chalk instance. */ +const cliHighlightChalk = createRequire(cliHighlightPath)("chalk") as { + level: number; +}; + +const CODE = "def greet(name):\n return f'hello {name}'"; + +let savedLevels: [typeof chalk.level, number]; + +beforeEach(() => { + savedLevels = [chalk.level, cliHighlightChalk.level]; + chalk.level = 3; + cliHighlightChalk.level = 3; +}); + +afterEach(() => { + [chalk.level, cliHighlightChalk.level] = savedLevels; + vi.unstubAllGlobals(); +}); + +describe("highlightCode", () => { + test("matches cli-highlight output when highlighting is available", () => { + const expected = highlight(CODE, { + language: "python", + ignoreIllegals: true, + }); + expect(expected).not.toBe(CODE); + expect(highlightCode(CODE, "python")).toBe(expected); + }); + + test("renders the fallback color when highlighting is compiled out", () => { + vi.stubGlobal("__SENTRY_CLI_SYNTAX_HIGHLIGHT__", false); + const expected = chalk.hex(COLORS.yellow)(CODE); + expect(expected).not.toBe(CODE); + expect(highlightCode(CODE, "python")).toBe(expected); + }); +}); diff --git a/packages/cli/test/script/syntax-highlight-bundle.test.ts b/packages/cli/test/script/syntax-highlight-bundle.test.ts new file mode 100644 index 000000000..5a1a26081 --- /dev/null +++ b/packages/cli/test/script/syntax-highlight-bundle.test.ts @@ -0,0 +1,67 @@ +/** + * Keep cli-highlight (all of highlight.js and parse5) behind + * `formatters/highlight.ts`: lazy in the CLI bundle, and removable with + * `__SENTRY_CLI_SYNTAX_HIGHLIGHT__ = false` for bundles like the MCP server. + */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { build, type Metafile } from "esbuild"; +import { expect, test } from "vitest"; + +const HIGHLIGHT_MODULE_RE = /formatters[\\/]highlight\.ts$/; +const HIGHLIGHT_STACK_RE = + /node_modules[\\/].*(?:cli-highlight|highlight\.js|parse5)/; + +/** Bundle the markdown renderer the way script/bundle.ts does. */ +async function bundleMarkdown(define: Record = {}) { + const { metafile } = await build({ + entryPoints: [ + join(import.meta.dirname, "../../src/lib/formatters/markdown.ts"), + ], + bundle: true, + write: false, + platform: "node", + format: "cjs", + metafile: true, + logLevel: "silent", + define, + plugins: [ + { + // Mirrors the require-alias plugin in script/bundle.ts. + name: "require-alias", + setup(builder) { + builder.onLoad({ filter: HIGHLIGHT_MODULE_RE }, async (args) => ({ + contents: (await readFile(args.path, "utf-8")).replace( + /\b_require\(/g, + "require(", + ), + loader: "ts", + })); + }, + }, + ], + }); + return metafile; +} + +function importsOf(metafile: Metafile, specifier: string) { + return Object.values(metafile.inputs).flatMap((input) => + input.imports.filter((imp) => imp.original === specifier), + ); +} + +test("cli-highlight is only reached through a lazy require", async () => { + const metafile = await bundleMarkdown(); + const imports = importsOf(metafile, "cli-highlight"); + expect(imports.length).toBeGreaterThan(0); + expect(imports.every((imp) => imp.kind === "require-call")).toBe(true); +}); + +test("the build-time define drops highlight.js and parse5", async () => { + const metafile = await bundleMarkdown({ + __SENTRY_CLI_SYNTAX_HIGHLIGHT__: "false", + }); + const bundled = Object.keys(metafile.inputs); + expect(bundled.filter((path) => HIGHLIGHT_STACK_RE.test(path))).toEqual([]); +}); From 85e554ea72765104c3ab9595b063cde7fc46c466 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:37:54 +0000 Subject: [PATCH 24/29] ref(cli): Read the route tree from a registry instead of app.ts help.ts and completions.ts imported the route map from app.ts, and list-command.ts loaded it lazily with require(). Every command imports help.ts through buildCommand, so bundling any one command pulled in the whole CLI: every other command, sentry init, and Mastra. app.ts now registers its route map with setRoutes() in lib/route-registry.ts, and help, completions, list-command, and schema read it from there. getRoutes() throws when nothing is registered yet. list-command uses findRoutes() so a command's func called on its own (as unit tests do) still runs without subcommand hints, as before. Another entry point can register a smaller map. Bundling commands/auth/whoami.ts goes from 1822 modules (5.6 MB) to 1156 (2.9 MB), and no longer includes app.ts or Mastra. A test bundles every module in src/lib/ and fails if one reaches app.ts, following lazy _require() loads too. The interceptSubcommand tests that were skipped because that require() failed under vitest now run. Co-Authored-By: BYK --- packages/cli/AGENTS.md | 9 ++ packages/cli/script/build.ts | 2 +- packages/cli/script/bundle.ts | 2 +- packages/cli/src/app.ts | 5 + packages/cli/src/commands/schema.ts | 4 +- packages/cli/src/lib/completions.ts | 4 +- packages/cli/src/lib/help.ts | 8 +- packages/cli/src/lib/list-command.ts | 61 +++---- packages/cli/src/lib/route-registry.ts | 42 +++++ .../cli/test/commands/schema-path.test.ts | 2 + packages/cli/test/lib/completions.test.ts | 2 + packages/cli/test/lib/help-json.test.ts | 2 + packages/cli/test/lib/help.test.ts | 2 + packages/cli/test/lib/route-registry.test.ts | 150 ++++++++++++++++++ .../cli/test/lib/route-subcommands.test.ts | 17 +- 15 files changed, 253 insertions(+), 59 deletions(-) create mode 100644 packages/cli/src/lib/route-registry.ts create mode 100644 packages/cli/test/lib/route-registry.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index b212ea208..db32055fd 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -225,6 +225,15 @@ export const myRoute = buildRouteMap({ }); ``` +**Reading the command tree:** code in `src/lib/` reads the top-level route map +with `getRoutes()` from `src/lib/route-registry.ts`, never by importing +`app.ts`. Every command reaches `src/lib/` through `buildCommand`, so that +import would pull every command into any bundle built from one of them. +`app.ts` registers its map with `setRoutes()`; a test that needs the real tree +imports `src/app.js`. `test/lib/route-registry.test.ts` fails when a module in +`src/lib/` reaches `app.ts`; the only exception is `sdk-invoke.ts`, the +library-mode runner that executes the whole application. + ### Positional Arguments Required identifiers (trace IDs, span IDs) should be **positional args**, not diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index e81731d7a..e771c3ce3 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -50,7 +50,7 @@ const NODE_VERSION = "lts"; /** Files whose lazy relative imports must be included in the native bundle. */ const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema)|formatters[\\/]json|list-command|telemetry)\.ts$/; + /(?:db[\\/](?:index|schema)|formatters[\\/]json|telemetry)\.ts$/; const REQUIRE_ALIAS_RE = /\b_require\(/g; /** Build-time constants injected into the binary */ diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index d6624cfca..ed3813947 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -169,7 +169,7 @@ const sentrySourcemapPlugin: Plugin = { * `formatters/json` loads its generated parser only when --fields is parsed. */ const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/]json|list-command|telemetry)\.ts$/; + /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/]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. diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 5359ae2de..805819c4f 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -93,6 +93,7 @@ import { buildTopLevelFlags } from "./lib/global-flags.js"; import { renderJsonHelp } from "./lib/help.js"; import { isRouteMap, type RouteMap } from "./lib/introspect.js"; import { buildRouteMap } from "./lib/route-map.js"; +import { setRoutes } from "./lib/route-registry.js"; /** * Plural alias → singular route name mapping. @@ -227,6 +228,10 @@ 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. * diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index d2593cfde..2826b9d1a 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -33,6 +33,7 @@ import { CommandOutput } from "../lib/formatters/output.js"; import { fuzzyMatch } from "../lib/fuzzy.js"; import { getCommandApiReferences, type RouteMap } from "../lib/introspect.js"; import { filterByName, isGlobFilter } from "../lib/list-filter.js"; +import { getRoutes } from "../lib/route-registry.js"; // --------------------------------------------------------------------------- // Output data types @@ -543,9 +544,8 @@ export const schemaCommand = buildCommand({ }); } - const { routes } = await import("../app.js"); const references = getCommandApiReferences( - routes as unknown as RouteMap, + getRoutes() as unknown as RouteMap, parseEndpointQuery(query).text, ); diff --git a/packages/cli/src/lib/completions.ts b/packages/cli/src/lib/completions.ts index 7e9e85fee..8e6f49ff9 100644 --- a/packages/cli/src/lib/completions.ts +++ b/packages/cli/src/lib/completions.ts @@ -16,8 +16,8 @@ import { existsSync, mkdirSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { routes } from "../app.js"; import { isRouteMap } from "./introspect.js"; +import { getRoutes } from "./route-registry.js"; import type { ShellType } from "./shell.js"; /** Where completions are installed */ @@ -55,7 +55,7 @@ export function extractCommandTree(): CommandTree { const groups: CommandGroup[] = []; const standalone: CommandEntry[] = []; - for (const entry of routes.getAllEntries()) { + for (const entry of getRoutes().getAllEntries()) { if (entry.hidden) { continue; } diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index e8ca36189..c5f8867f4 100644 --- a/packages/cli/src/lib/help.ts +++ b/packages/cli/src/lib/help.ts @@ -6,7 +6,6 @@ * Commands are auto-generated from Stricli's route structure. */ -import { routes } from "../app.js"; import { formatBanner } from "./banner.js"; import { isAuthenticated } from "./db/auth.js"; import { detectAgent } from "./detect-agent.js"; @@ -29,6 +28,7 @@ import { type RouteMapEntry, resolveCommandPath, } from "./introspect.js"; +import { getRoutes } from "./route-registry.js"; import { graphicsBanner, graphicsBannerExtraRows } from "./sixel.js"; const TAGLINE = "The command-line interface for Sentry"; @@ -112,7 +112,7 @@ const MIN_OVERVIEW_COMMANDS = 3; /** Visible top-level route entries, in registration order. */ function visibleRouteEntries(): RouteMapEntry[] { // Cast to our introspection types — Stricli's generic types are compatible - const routeMap = routes as unknown as RouteMap; + const routeMap = getRoutes() as unknown as RouteMap; return routeMap .getAllEntries() .filter((entry: RouteMapEntry) => !entry.hidden); @@ -460,7 +460,7 @@ export function introspectAllCommands(): { envVars: HelpEnvVarInfo[]; flags: HelpFlagInfo[]; } { - const routeMap = routes as unknown as RouteMap; + const routeMap = getRoutes() as unknown as RouteMap; return { routes: extractAllRoutes(routeMap), envVars: buildTopLevelEnvVars(), @@ -476,7 +476,7 @@ export function introspectAllCommands(): { export function introspectCommand( commandPath: string[], ): CommandInfo | RouteInfo | { error: string; suggestions?: string[] } { - const routeMap = routes as unknown as RouteMap; + const routeMap = getRoutes() as unknown as RouteMap; const resolved = resolveCommandPath(routeMap, commandPath); if (!resolved) { return { error: `Command not found: ${commandPath.join(" ")}` }; diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 388b6bc01..0e3550e64 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -13,12 +13,8 @@ * buildOrgListCommand */ -import { createRequire } from "node:module"; import type { Aliases, Command, CommandContext } from "@stricli/core"; import type { SentryContext } from "../context.js"; - -const _require = createRequire(import.meta.url); - import type { ListTargetOptions } from "./arg-parsing.js"; import { buildCommand, @@ -27,10 +23,6 @@ import { } from "./command.js"; import { ORG_PROJECT_TARGET, ORG_TARGET } from "./completion-target.js"; import { disableOrgCache } from "./db/regions.js"; -import { logger } from "./logger.js"; - -const log = logger.withTag("list-command"); - import { disableDsnCache } from "./dsn/index.js"; import { warning } from "./formatters/colors.js"; import { @@ -51,6 +43,7 @@ import { skippedArgument, } from "./positional-parse.js"; import { disableResponseCache } from "./response-cache.js"; +import { type CliRoutes, findRoutes } from "./route-registry.js"; import { type ListCommandTarget, listTarget } from "./target-positional.js"; import { PERIOD_BRIEF, parsePeriod } from "./time-range.js"; import { shellQuote } from "./utils.js"; @@ -445,7 +438,8 @@ export const LIST_BASE_ALIASES: Aliases = { n: "limit", c: "cursor" }; // Level B: subcommand interception for plural aliases // --------------------------------------------------------------------------- -let _subcommandsByRoute: Map> | undefined; +/** Subcommand names by route group, per registered route map. */ +const subcommandsByRoutes = new WeakMap>>(); /** * Entry shape returned by Stricli's getAllEntries(). @@ -475,39 +469,32 @@ function collectChildNames(parent: { /** * Get the subcommand names for a given singular route (e.g. "project" → {"list", "view"}). * - * Lazily walks the Stricli route map on first call. Uses `require()` to break - * the circular dependency: list-command → app → commands → list-command. + * Walks the registered route map once and caches the result for it. With no + * route map registered (a command's `func` called directly, as in unit + * tests), there are no subcommands to intercept. */ function getSubcommandsForRoute(routeName: string): Set { - if (!_subcommandsByRoute) { - _subcommandsByRoute = new Map(); - - try { - const { routes } = _require("../app.js") as { - routes: { getAllEntries: () => readonly RouteEntry[] }; - }; - - for (const entry of routes.getAllEntries()) { - const target = entry.target as unknown as Record; - if (typeof target?.getAllEntries === "function") { - _subcommandsByRoute.set( - entry.name.original, - collectChildNames( - target as { getAllEntries: () => readonly RouteEntry[] }, - ), - ); - } + const routes = findRoutes(); + if (!routes) { + return new Set(); + } + let byRoute = subcommandsByRoutes.get(routes); + if (!byRoute) { + byRoute = new Map(); + for (const entry of routes.getAllEntries()) { + const target = entry.target as unknown as Record; + if (typeof target?.getAllEntries === "function") { + byRoute.set( + entry.name.original, + collectChildNames( + target as { getAllEntries: () => readonly RouteEntry[] }, + ), + ); } - } catch (error) { - // In test environments (vitest), require("../app.js") may fail because - // Node's ESM resolver can't resolve .js→.ts for transitive imports. - // Gracefully degrade: interceptSubcommand will treat all targets as - // plain values (no subcommand interception). - log.debug("Failed to load app routes for subcommand detection", error); } + subcommandsByRoutes.set(routes, byRoute); } - - return _subcommandsByRoute.get(routeName) ?? new Set(); + return byRoute.get(routeName) ?? new Set(); } /** diff --git a/packages/cli/src/lib/route-registry.ts b/packages/cli/src/lib/route-registry.ts new file mode 100644 index 000000000..8033c5d82 --- /dev/null +++ b/packages/cli/src/lib/route-registry.ts @@ -0,0 +1,42 @@ +/** + * The route map the running CLI was built from. + * + * Library modules (help, completions, list-command) need the command tree, + * but importing `app.ts` for it would pull every command into any bundle + * that reaches those modules — and every command reaches them through + * `buildCommand`. Instead, whatever builds the application registers its + * route map here: `app.ts` for the full CLI, or another entry point with a + * smaller map. Tests can register a fake one. + */ + +import type { RouteMap } from "@stricli/core"; +import type { SentryContext } from "../context.js"; + +/** Top-level route map of the CLI application. */ +export type CliRoutes = RouteMap; + +let registeredRoutes: CliRoutes | undefined; + +/** Register the route map the CLI application runs. Replaces any earlier one. */ +export function setRoutes(routes: CliRoutes): void { + registeredRoutes = routes; +} + +/** The registered route map, or `undefined` before registration. */ +export function findRoutes(): CliRoutes | undefined { + return registeredRoutes; +} + +/** + * The registered route map. + * + * @throws {Error} When no entry point has called {@link setRoutes} yet + */ +export function getRoutes(): CliRoutes { + if (!registeredRoutes) { + throw new Error( + "CLI routes are not registered. The entry point that builds the application must call setRoutes() first (src/app.ts does).", + ); + } + return registeredRoutes; +} diff --git a/packages/cli/test/commands/schema-path.test.ts b/packages/cli/test/commands/schema-path.test.ts index 965d235e1..b73e56b54 100644 --- a/packages/cli/test/commands/schema-path.test.ts +++ b/packages/cli/test/commands/schema-path.test.ts @@ -3,6 +3,8 @@ */ import { describe, expect, test, vi } from "vitest"; +// Registers the CLI route map that command API references are read from. +import "../../src/app.js"; import { schemaCommand } from "../../src/commands/schema.js"; import type { SentryContext } from "../../src/context.js"; import { getAllEndpoints } from "../../src/lib/api-schema.js"; diff --git a/packages/cli/test/lib/completions.test.ts b/packages/cli/test/lib/completions.test.ts index b41c3dd1a..d02b1377c 100644 --- a/packages/cli/test/lib/completions.test.ts +++ b/packages/cli/test/lib/completions.test.ts @@ -10,6 +10,8 @@ import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +// Registers the CLI route map the completion scripts are generated from. +import "../../src/app.js"; import { extractCommandTree, getCompletionPath, diff --git a/packages/cli/test/lib/help-json.test.ts b/packages/cli/test/lib/help-json.test.ts index 4c6d06ee7..09e70dc36 100644 --- a/packages/cli/test/lib/help-json.test.ts +++ b/packages/cli/test/lib/help-json.test.ts @@ -9,6 +9,8 @@ */ import { describe, expect, test } from "vitest"; +// Registers the CLI route map the help output is built from. +import "../../src/app.js"; import { isRecoverableUnknownCommand, isVersionRequest, diff --git a/packages/cli/test/lib/help.test.ts b/packages/cli/test/lib/help.test.ts index 2069b402e..d2b8e6907 100644 --- a/packages/cli/test/lib/help.test.ts +++ b/packages/cli/test/lib/help.test.ts @@ -6,6 +6,8 @@ */ import { describe, expect, test } from "vitest"; +// Registers the CLI route map the help output is built from. +import "../../src/app.js"; import { bannerLinesForWidth, formatBanner } from "../../src/lib/banner.js"; import { withEnv } from "../../src/lib/env.js"; import { introspectAllCommands, printCustomHelp } from "../../src/lib/help.js"; diff --git a/packages/cli/test/lib/route-registry.test.ts b/packages/cli/test/lib/route-registry.test.ts new file mode 100644 index 000000000..41d4a1ad7 --- /dev/null +++ b/packages/cli/test/lib/route-registry.test.ts @@ -0,0 +1,150 @@ +/** + * The route registry: library modules read the command tree from whichever + * entry point built the application, never from `app.ts` itself. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { build } from "esbuild"; +import { describe, expect, test, vi } from "vitest"; +import { buildCommand } from "../../src/lib/command.js"; +import { extractCommandTree } from "../../src/lib/completions.js"; +import { introspectAllCommands } from "../../src/lib/help.js"; +import { interceptSubcommand } from "../../src/lib/list-command.js"; +import { buildRouteMap } from "../../src/lib/route-map.js"; +import { setRoutes } from "../../src/lib/route-registry.js"; + +const SRC = join(import.meta.dirname, "../../src"); +const APP = join(SRC, "app.ts"); +/** Library-mode runner: executes the full application, reached only from `src/index.ts`. */ +const SDK_RUNNER = join(SRC, "lib/sdk-invoke.ts"); + +const ANY_IMPORT = /.*/; +const RELATIVE_IMPORT = /^\.\.?\//; +const JS_EXTENSION = /\.js$/; +const TS_SOURCE = /\.tsx?$/; +const LAZY_REQUIRE = /\b_require\(/g; + +function command(brief: string) { + return buildCommand({ + docs: { brief }, + auth: false, + async *func() { + yield; + }, + }); +} + +/** A smaller CLI, like the one the MCP server build registers. */ +function mcpServerRoutes() { + return buildRouteMap({ + routes: { + mcp: command("Run the MCP server"), + auth: buildRouteMap({ + routes: { login: command("Log in"), whoami: command("Who am I") }, + docs: { brief: "Authenticate" }, + }), + help: command("Help"), + }, + defaultCommand: "mcp", + docs: { brief: "Sentry MCP server" }, + }); +} + +describe("route registry", () => { + test("reading routes before an entry point registers them fails clearly", async () => { + vi.resetModules(); + const { getRoutes } = await import("../../src/lib/route-registry.js"); + expect(() => getRoutes()).toThrow("CLI routes are not registered"); + }); + + test("help, completions, and subcommand hints read the registered map", () => { + setRoutes(mcpServerRoutes()); + + expect(extractCommandTree()).toEqual({ + groups: [ + { + name: "auth", + brief: "Authenticate", + subcommands: [ + { name: "login", brief: "Log in" }, + { name: "whoami", brief: "Who am I" }, + ], + }, + ], + standalone: [ + { name: "mcp", brief: "Run the MCP server" }, + { name: "help", brief: "Help" }, + ], + }); + expect(introspectAllCommands().routes.map((route) => route.name)).toEqual([ + "mcp", + "auth", + "help", + ]); + + let hint = ""; + const stderr = { write: (s: string) => (hint += s) }; + expect(interceptSubcommand("login", stderr, "auth")).toBeUndefined(); + expect(hint).toContain("sentry auth login"); + }); + + test("library modules never import app.ts", async () => { + const files = await readdir(join(SRC, "lib"), { recursive: true }); + const entryPoints = files + .filter((file) => TS_SOURCE.test(file)) + .map((file) => join(SRC, "lib", file)) + .filter((file) => file !== SDK_RUNNER); + + await expect( + build({ + entryPoints, + bundle: true, + write: false, + outdir: "out", + splitting: true, + platform: "node", + format: "esm", + logLevel: "silent", + plugins: [ + { + name: "check-app-imports", + setup(builder) { + builder.onResolve({ filter: ANY_IMPORT }, (args) => { + if (args.kind === "entry-point") { + return; + } + // Only our own modules matter; `with { type }` imports are assets. + if (!RELATIVE_IMPORT.test(args.path) || args.with.type) { + return { path: args.path, external: true }; + } + const target = resolve(args.resolveDir, args.path).replace( + JS_EXTENSION, + ".ts", + ); + if (target === APP) { + return { + errors: [ + { + text: `${relative(SRC, args.importer)} imports app.ts; read routes from lib/route-registry.ts instead`, + }, + ], + }; + } + return; + }); + // Follow lazy `_require()` loads too, as the bundle scripts do. + builder.onLoad({ filter: TS_SOURCE }, async (args) => ({ + contents: (await readFile(args.path, "utf-8")).replace( + LAZY_REQUIRE, + "require(", + ), + loader: args.path.endsWith(".tsx") ? "tsx" : "ts", + })); + }, + }, + ], + }), + ).resolves.toMatchObject({ errors: [] }); + }); +}); diff --git a/packages/cli/test/lib/route-subcommands.test.ts b/packages/cli/test/lib/route-subcommands.test.ts index 65f5c2005..e7e42c0d6 100644 --- a/packages/cli/test/lib/route-subcommands.test.ts +++ b/packages/cli/test/lib/route-subcommands.test.ts @@ -3,6 +3,8 @@ */ import { describe, expect, test } from "vitest"; +// Registers the CLI route map that subcommand names are read from. +import "../../src/app.js"; import { interceptSubcommand } from "../../src/lib/list-command.js"; function makeStderr(): { write(s: string): void; output: string } { @@ -18,12 +20,7 @@ function makeStderr(): { write(s: string): void; output: string } { } describe("interceptSubcommand", () => { - // Skip: interceptSubcommand relies on require("../app.js") to load the - // Stricli route map, which fails under vitest because Node's CJS require - // can't resolve .js→.ts for transitive ESM imports. The try-catch in - // getSubcommandsForRoute gracefully degrades to empty sets in test. - // oxlint-disable-next-line sentry-cli/no-skipped-tests -- require("../app.js") fails in vitest — CJS can't resolve .js→.ts - test.skip("returns undefined and writes hint for known subcommand", () => { + test("returns undefined and writes hint for known subcommand", () => { const stderr = makeStderr(); const result = interceptSubcommand("list", stderr, "project"); expect(result).toBeUndefined(); @@ -54,17 +51,13 @@ describe("interceptSubcommand", () => { expect(stderr.output).toBe(""); }); - // Skip: same reason as above — require("../app.js") fails in vitest - // oxlint-disable-next-line sentry-cli/no-skipped-tests -- require("../app.js") fails in vitest — CJS can't resolve .js→.ts - test.skip("hint includes the route name and subcommand", () => { + test("hint includes the route name and subcommand", () => { const stderr = makeStderr(); interceptSubcommand("view", stderr, "issue"); expect(stderr.output).toContain("sentry issue view"); }); - // Skip: same reason as above — require("../app.js") fails in vitest - // oxlint-disable-next-line sentry-cli/no-skipped-tests -- require("../app.js") fails in vitest — CJS can't resolve .js→.ts - test.skip("handles 'explain' and 'plan' subcommands for issue route", () => { + test("handles 'explain' and 'plan' subcommands for issue route", () => { const stderr1 = makeStderr(); expect(interceptSubcommand("explain", stderr1, "issue")).toBeUndefined(); expect(stderr1.output).toContain("sentry issue explain"); From 261e371f0d07c65cb08cc92e8e2adc14d72c8a22 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:37:56 +0000 Subject: [PATCH 25/29] feat(mcp): Serve stdio on MCP SDK v2 `sentry mcp` and `@sentry/mcp-server` now build the server with `sdkVersion: "v2"` and connect it through `@modelcontextprotocol/server`'s stdio transport, the SDK the Cloudflare worker already runs. Both serve the same 14 tools as before. v2's transport closes when the client closes stdin, and `startStdio()` now returns a `closed` promise for that, so a host can tear down only after the session ends. `buildServer()` still defaults to v1 for other callers. Co-Authored-By: BYK --- packages/cli/src/lib/mcp/server.ts | 1 + packages/mcp-core/src/server.ts | 5 +-- packages/mcp-core/src/transports/stdio.ts | 31 +++++++++++++++---- .../src/bin/start-mock-stdio.ts | 6 +++- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 57bf08f71..9a1080a59 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -387,6 +387,7 @@ export function serveMcpStdio( const server = buildServer({ context, experimentalMode: config.experimental, + sdkVersion: "v2", }); startStdio(server, context).catch((error: unknown) => { exitAfterError(error, "Server error:"); diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index 42770317e..bae433683 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -123,8 +123,9 @@ function structuredOutputToCallToolResult( * constraints: {} * }; * - * const server = buildServer({ context }); - * await startStdio(server, context); + * const server = buildServer({ context, sdkVersion: "v2" }); + * const { closed } = await startStdio(server, context); + * await closed; * ``` * */ diff --git a/packages/mcp-core/src/transports/stdio.ts b/packages/mcp-core/src/transports/stdio.ts index 731501c25..0423a772d 100644 --- a/packages/mcp-core/src/transports/stdio.ts +++ b/packages/mcp-core/src/transports/stdio.ts @@ -10,12 +10,16 @@ * import { buildServer } from "@sentry/mcp-core/server"; * import { startStdio } from "@sentry/mcp-core/transports/stdio"; * - * const server = buildServer({ context }); - * await startStdio(server, context); + * const server = buildServer({ context, sdkVersion: "v2" }); + * 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/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { McpServer } from "@modelcontextprotocol/server"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { startNewTrace, startSpan } from "@sentry/core"; import { LIB_VERSION } from "../version"; @@ -46,6 +50,12 @@ function getStdioSpanAttributes( 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. * @@ -55,7 +65,8 @@ function getStdioSpanAttributes( export async function startStdio( server: McpServer, context: Context, -) { +): Promise { + const transport = new StdioServerTransport(); await startNewTrace(async () => { return await startSpan( { @@ -63,9 +74,17 @@ export async function startStdio( attributes: getStdioSpanAttributes(context), }, async () => { - const transport = new StdioServerTransport(); 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-server-evals/src/bin/start-mock-stdio.ts b/packages/mcp-server-evals/src/bin/start-mock-stdio.ts index b0b3040af..78ccf9e48 100644 --- a/packages/mcp-server-evals/src/bin/start-mock-stdio.ts +++ b/packages/mcp-server-evals/src/bin/start-mock-stdio.ts @@ -36,7 +36,11 @@ const context: ServerContext = { }, }; -const server = buildServer({ context, experimentalMode: true }); +const server = buildServer({ + context, + experimentalMode: true, + sdkVersion: "v2", +}); // Run in-process MCP with all skills so MSW mocks apply startStdio(server, context).catch((err: unknown) => { From ffeaf9ba6855770b629fe96327cf5aaab43f048c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:38:05 +0000 Subject: [PATCH 26/29] ref(mcp-server): Run the CLI runner with an mcp, auth, and help app `@sentry/mcp-server` no longer has its own entry logic. `mcp-server.ts` runs `cli.ts`'s runner with `mcp-server-app.ts`, a route map of the CLI's real `mcp` (the default command), `auth`, and `help`. So `sentry-mcp auth status|list|switch|logout` work, and flags and help for `sentry mcp` and `sentry-mcp` come from one Stricli definition. - The runner takes a `CliEntry` that loads the application, and starts `mcp` without the CLI's telemetry, recovery middleware, and update check. The command resolves when the client closes stdin, and only then does the runner release the network. - `lib/app-identity.ts` replaces `login-command.ts`: hints and help examples name `sentry-mcp`, or `npx -y @sentry/mcp-server` under npx. - `--flag=` and values with line breaks still parse, as they did with the old parser (`splitInlineFlagValues`). - A bare `--version` answers before the application loads (86 MB, 140 ms). The server stack loads only when `mcp` serves. - The bundle adds #1763's Zod English-locale plugin, compiles out syntax highlighting and the Ink picker, and stubs MCP SDK v1's `McpServer` and `zod/v3`. It is 3,689,911 bytes; the budget is 3.75 MiB. Co-Authored-By: BYK --- docs/operations/stdio-auth.md | 18 +- docs/releases/stdio.md | 45 ++- docs/specs/embedded-agent-openai-routing.md | 2 +- docs/testing/stdio.md | 2 +- .../skills/sentry-cli/references/mcp.md | 38 ++ packages/cli/src/app.ts | 286 +------------- packages/cli/src/bin.ts | 3 +- packages/cli/src/cli.ts | 121 +++--- packages/cli/src/commands/auth/whoami.ts | 2 +- packages/cli/src/commands/mcp.ts | 118 +++++- packages/cli/src/index.ts | 7 +- packages/cli/src/lib/account-selection.ts | 6 +- packages/cli/src/lib/app-identity.ts | 69 ++++ packages/cli/src/lib/application.ts | 353 ++++++++++++++++++ packages/cli/src/lib/command.ts | 3 +- packages/cli/src/lib/custom-headers.ts | 4 +- packages/cli/src/lib/db/auth.ts | 4 +- packages/cli/src/lib/db/migration.ts | 4 +- packages/cli/src/lib/errors.ts | 12 +- packages/cli/src/lib/help.ts | 3 +- packages/cli/src/lib/login-command.ts | 28 -- packages/cli/src/lib/login-host-guard.ts | 4 +- packages/cli/src/lib/mcp.ts | 86 ++--- packages/cli/src/lib/mcp/args.ts | 187 ++++------ packages/cli/src/lib/mcp/auth.ts | 34 -- packages/cli/src/lib/mcp/config.ts | 8 +- packages/cli/src/lib/mcp/run.ts | 37 -- packages/cli/src/lib/mcp/server.ts | 62 ++- packages/cli/src/lib/mcp/usage.ts | 81 ---- packages/cli/src/lib/oauth.ts | 6 +- packages/cli/src/lib/resolve-target.ts | 6 +- packages/cli/src/lib/select-prompt.ts | 43 +++ packages/cli/src/mcp-server-app.ts | 32 ++ packages/cli/src/mcp-server.ts | 61 ++- packages/cli/src/sentry-cli.ts | 27 ++ packages/cli/test/lib/app-identity.test.ts | 54 +++ packages/cli/test/lib/cli-startup.test.ts | 3 +- packages/cli/test/lib/mcp-auth.test.ts | 73 ++-- packages/cli/test/lib/mcp-command.test.ts | 198 +++++++++- packages/cli/test/lib/mcp-run.test.ts | 75 ---- packages/cli/test/lib/mcp-start.test.ts | 71 ++-- packages/cli/test/lib/mcp/args.test.ts | 252 ++++++------- packages/cli/test/lib/mcp/auth.test.ts | 76 ---- packages/cli/test/lib/mcp/config.test.ts | 64 +--- packages/cli/test/lib/mcp/parse-flags.ts | 57 +++ packages/cli/test/lib/mcp/telemetry.test.ts | 8 +- .../cli/test/lib/mcp/usage-standalone.test.ts | 31 -- packages/cli/test/lib/mcp/usage.test.ts | 88 ----- packages/mcp-server/README.md | 16 +- packages/mcp-server/script/bundle.ts | 78 ++-- packages/mcp-server/test/bundle.test.mjs | 51 ++- 51 files changed, 1544 insertions(+), 1453 deletions(-) create mode 100644 packages/cli/src/lib/app-identity.ts create mode 100644 packages/cli/src/lib/application.ts delete mode 100644 packages/cli/src/lib/login-command.ts delete mode 100644 packages/cli/src/lib/mcp/auth.ts delete mode 100644 packages/cli/src/lib/mcp/run.ts delete mode 100644 packages/cli/src/lib/mcp/usage.ts create mode 100644 packages/cli/src/lib/select-prompt.ts create mode 100644 packages/cli/src/mcp-server-app.ts create mode 100644 packages/cli/src/sentry-cli.ts create mode 100644 packages/cli/test/lib/app-identity.test.ts delete mode 100644 packages/cli/test/lib/mcp-run.test.ts delete mode 100644 packages/cli/test/lib/mcp/auth.test.ts create mode 100644 packages/cli/test/lib/mcp/parse-flags.ts delete mode 100644 packages/cli/test/lib/mcp/usage-standalone.test.ts delete mode 100644 packages/cli/test/lib/mcp/usage.test.ts diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index f7cedb070..ac2451120 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -2,8 +2,8 @@ How the stdio MCP server (`@sentry/mcp-server`) authenticates with Sentry. -Sign in once with `npx -y @sentry/mcp-server auth login`. The package ships -its own OAuth device flow and stores the session in the Sentry CLI's +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. @@ -28,9 +28,9 @@ In order of precedence: 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 (`packages/cli/src/lib/login-command.ts`). The one -exception is confirming a self-hosted host with `--url`: the package's -`auth login` takes no flags yet, so that hint names the CLI. +program that is running: `sentry auth login`, `sentry-mcp auth login`, or +`npx -y @sentry/mcp-server auth login` under npx +(`packages/cli/src/lib/app-identity.ts`). For self-hosted Sentry, set the host with `--host`/`--url` or `SENTRY_HOST`/`SENTRY_URL`. Only the environment variables also pick the stored @@ -63,16 +63,16 @@ MCP servers, for example one per host via `SENTRY_HOST`, or one with a static Older releases had their own device code flow, bundled client ID, and token cache (`~/.sentry/mcp.json`, `SENTRY_MCP_AUTH_CACHE`). Current releases do not read that cache. Run `npx -y @sentry/mcp-server auth login` once, or keep -passing `--access-token` / `SENTRY_ACCESS_TOKEN`. The package does not have -`auth logout` or `auth status` yet; the CLI's commands work on the same -session. +passing `--access-token` / `SENTRY_ACCESS_TOKEN`. `auth status`, `auth list`, +`auth switch`, and `auth logout` work in the package as in the CLI. ## Key Files | File | Purpose | | ------------------------------------------- | -------------------------------------------------------------- | | `packages/cli/src/lib/mcp.ts` | Credential resolution, static-token host check, refresh-on-401 | -| `packages/cli/src/lib/mcp/server.ts` | Flag/env parsing, telemetry, and stdio startup | +| `packages/cli/src/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/stdio.md b/docs/releases/stdio.md index d55a0f2d9..6b60e978d 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -25,17 +25,34 @@ 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`: -- Full flags, environment variables, tools, and self-hosted support, using the - CLI's credential store. -- `sentry-mcp auth login` runs the CLI's device flow with plain-text output. - Other `auth` subcommands point to `npx -y sentry auth ...`. -- CLI-only code stays out: commands, the markdown renderer, agent skills, - completions, terminal images, the Ink UI, and debug-file processing. The - SQLite WASM fallback is left out too, so the package needs Node.js 22.15+. - The bundle script fails if any of these reach the output or the file grows - past its size budget. Packages that only those code paths import (Ink, - React, the SQLite WASM fallback) resolve to empty stub modules, so the - output never `require`s a package the tarball does not ship. +- 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` returns before the application loads. +- stdio runs on MCP SDK v2 (`@modelcontextprotocol/server`), like the + Cloudflare worker. The bundle stubs SDK v1's `McpServer`, which + `buildServer()` still imports for its default, and `zod/v3`, which only v1 + and an unused `@ai-sdk/provider-utils` Zod 3 path import. 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. @@ -119,8 +136,10 @@ While iterating, `pnpm -w run cli --transport stdio` (without ## References -- Command entry: `packages/cli/src/lib/mcp.ts` -- Host (flags, env, telemetry): `packages/cli/src/lib/mcp/` +- 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 0c66639c8..a0dc36072 100644 --- a/docs/specs/embedded-agent-openai-routing.md +++ b/docs/specs/embedded-agent-openai-routing.md @@ -126,7 +126,7 @@ Update: - `packages/mcp-core/src/internal/agents/provider-factory.ts` - `packages/cli/src/lib/mcp/args.ts` - `packages/cli/src/lib/mcp/config.ts` -- `packages/cli/src/lib/mcp/usage.ts` +- `packages/cli/src/commands/mcp.ts` Add: diff --git a/docs/testing/stdio.md b/docs/testing/stdio.md index 75aa5fe10..509ef9416 100644 --- a/docs/testing/stdio.md +++ b/docs/testing/stdio.md @@ -68,7 +68,7 @@ Source runs skip the bundling step, so check the bundle that ships as ```bash pnpm --filter @sentry/mcp-server run bundle -node packages/mcp-server/dist/sentry-mcp.cjs --help +node packages/mcp-server/dist/sentry-mcp.cjs mcp --help node packages/mcp-server/dist/sentry-mcp.cjs --access-token=YOUR_TOKEN # Or drive it with the test client 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..dc1bfb4a5 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 - Use this Sentry token instead of the CLI login (never refreshed)` +- `--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/src/app.ts b/packages/cli/src/app.ts index 805819c4f..6b9981860 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,8 @@ 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 { buildRouteMap } from "./lib/route-map.js"; -import { setRoutes } from "./lib/route-registry.js"; /** * Plural alias → singular route name mapping. @@ -228,257 +199,6 @@ 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 = 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, - }, +export const app = buildCliApplication(routes, { + pluralAliases: PLURAL_TO_SINGULAR, }); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index d5100c159..579edfbec 100755 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -11,6 +11,7 @@ import "./lib/suppress-sqlite-warning.js"; import { startCli } from "./cli.js"; import { wrapCall } from "./lib/react-native/wrap-call.js"; import { installStreamErrorHandlers } from "./lib/stream-errors.js"; +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 @@ -23,6 +24,6 @@ if (process.env.__SENTRY_RN_WRAP_XCODE_CALL === "1") { installStreamErrorHandlers(); // startCli handles its own errors — this is a safety net for truly unexpected rejections -startCli().catch(() => { +startCli(SENTRY_CLI).catch(() => { process.exitCode = 1; }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6e8bba24f..4dc75e5ee 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,31 +5,34 @@ * to avoid loading `@sentry/node` (~100ms). All other commands go through * the full CLI with telemetry, middleware, and error recovery. * + * 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"; -/** - * Fast-path: shell completion. - * - * Dispatched before importing the full CLI to avoid loading @sentry/node, - * @stricli/core, and other heavy dependencies. Only loads the lightweight - * completion engine and SQLite cache modules. - */ -export async function runCompletion(completionArgs: string[]): Promise { - // Disable telemetry so db/index.ts skips the @sentry/node lazy-require (~100ms) - getEnv().SENTRY_CLI_NO_TELEMETRY = "1"; - const { handleComplete } = await import("./lib/complete.js"); - await handleComplete(completionArgs); -} +/** What a binary runs: its application, and optionally shell completion. */ +export type CliEntry = { + /** Load the application. Loading it registers its routes. */ + readonly loadApp: () => Promise<{ + app: CliApplication; + routes: CliRoutes; + }>; + /** Handle `__complete` without loading the application. */ + readonly complete?: (completionArgs: string[]) => Promise; +}; /** * Flags whose values must never be sent to telemetry. @@ -181,12 +184,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, @@ -206,36 +204,64 @@ 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; +/** + * 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 run(app, splitInlineFlagValues(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(); } - - const { runMcpServer } = await import("./lib/mcp/run.js"); - await runMcpServer(mcpArgs); - return true; } /** @@ -244,14 +270,18 @@ export async function runMcpCommand(cliArgs: string[]): Promise { * All heavy imports are loaded here (not at module top level) so the * `__complete` fast-path can skip them entirely. */ -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"); @@ -556,7 +586,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 @@ -681,7 +710,7 @@ export async function runCli(cliArgs: string[]): Promise { * Reads `process.argv`, dispatches to the completion fast-path or the full * CLI runner, 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 @@ -690,8 +719,8 @@ export async function startCli(): Promise { initTimezone(); // Completions are a fast-path (~1ms) — skip .sentryclirc I/O. - if (args[0] === "__complete") { - return await runCompletion(args.slice(1)).catch(() => { + if (args[0] === "__complete" && entry.complete) { + return await entry.complete(args.slice(1)).catch(() => { // Completions should never crash — silently return no results process.exitCode = 0; }); @@ -713,7 +742,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..509baecd9 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -1,22 +1,118 @@ +/** + * `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 { ALL_SKILLS } from "@sentry/mcp-core/skills"; 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"; + +/** 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: a static token, or the CLI + // session, 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 pass a token with --access-token or SENTRY_ACCESS_TOKEN.\n\n" + + "The token from --access-token or SENTRY_ACCESS_TOKEN is used as given and never refreshed, " + + "even when a CLI login exists. An org auth token (sntrys_) only works with the host it was issued for.\n\n" + + "Environment variables:\n" + + " SENTRY_ACCESS_TOKEN Static Sentry token (same as --access-token)\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: ${ALL_SKILLS.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( + "Use this Sentry token instead of the CLI login (never refreshed)", + ), + 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); }, }); 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 1d73316c5..536ecf07b 100644 --- a/packages/cli/src/lib/custom-headers.ts +++ b/packages/cli/src/lib/custom-headers.ts @@ -36,7 +36,7 @@ import { getActiveTokenHost, isRequestOriginTrustedForCustomHeaders, } from "./token-host.js"; -import { URL_LOGIN_COMMAND } from "./login-command.js"; +import { getUrlLoginCommand } from "./app-identity.js"; const log = logger.withTag("custom-headers"); @@ -353,7 +353,7 @@ export function applyCustomHeaders( untrustedDestinationWarningLogged = true; log.warn( "Skipping custom headers for request to untrusted host. " + - `If this is legitimate, run '${URL_LOGIN_COMMAND} ' 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 290ac85a8..65ec6f1d1 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -68,7 +68,7 @@ import { getDatabase } from "./index.js"; import { clearCompletionCache } from "./completion-cache.js"; import { clearAllIssueOrgCache } from "./issue-org-cache.js"; import { clearTrustedHostState } from "./regions.js"; -import { LOGIN_COMMAND } from "../login-command.js"; +import { getLoginCommand } from "../app-identity.js"; /** Refresh when less than 10% of token lifetime remains */ export const REFRESH_THRESHOLD = 0.1; @@ -1729,7 +1729,7 @@ export async function refreshToken( } throw new AuthError( "expired", - `Session expired and no refresh token available. Run '${LOGIN_COMMAND}'.`, + `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 88a90e366..941cb2aa2 100644 --- a/packages/cli/src/lib/db/migration.ts +++ b/packages/cli/src/lib/db/migration.ts @@ -14,7 +14,7 @@ import { MalformedAuthTokenError } from "../errors.js"; import { logger } from "../logger.js"; import { getConfigDir } from "./index.js"; import type { Database } from "./sqlite.js"; -import { LOGIN_COMMAND } from "../login-command.js"; +import { getLoginCommand } from "../app-identity.js"; const log = logger.withTag("migration"); @@ -306,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 '${LOGIN_COMMAND}' 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/errors.ts b/packages/cli/src/lib/errors.ts index a36ac7eee..909286f91 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -30,7 +30,7 @@ import { buildOrgSettingsUrl, buildSeerSettingsUrl, } from "./sentry-urls.js"; -import { LOGIN_COMMAND, URL_LOGIN_COMMAND } from "./login-command.js"; +import { getLoginCommand, getUrlLoginCommand } from "./app-identity.js"; // --------------------------------------------------------------------------- // Exit code constants @@ -153,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: ${URL_LOGIN_COMMAND} ${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: ${URL_LOGIN_COMMAND} ${destinationUrl}\n` + + `To use this host, run: ${getUrlLoginCommand()} ${destinationUrl}\n` + "To keep using your current credentials, remove this URL override.", EXIT.AUTH_HOST_SCOPE, ); @@ -273,8 +273,8 @@ export class AuthError extends CliError { options?: AuthErrorOptions, ) { const defaultMessages: Record = { - not_authenticated: `Not authenticated. Run '${LOGIN_COMMAND}' first.`, - expired: `Authentication expired. Run '${LOGIN_COMMAND}' 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 '${LOGIN_COMMAND}' to replace stored credentials.`, + `or run '${getLoginCommand()}' to replace stored credentials.`, ); this.name = "MalformedAuthTokenError"; } diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index c5f8867f4..67b05e0a7 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"; @@ -836,7 +837,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-command.ts b/packages/cli/src/lib/login-command.ts deleted file mode 100644 index b8c325369..000000000 --- a/packages/cli/src/lib/login-command.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * The sign-in commands that auth hints name. The standalone - * `@sentry/mcp-server` bundle has its own `auth login`, so its users are not - * sent to a CLI they may not have installed. - */ - -/** Set by `packages/mcp-server/script/bundle.ts` to the `@sentry/mcp-server` version. */ -declare const SENTRY_MCP_SERVER_VERSION: string | undefined; - -/** The `@sentry/mcp-server` version when running as that bundle. */ -export const MCP_SERVER_VERSION = - typeof SENTRY_MCP_SERVER_VERSION === "undefined" - ? undefined - : SENTRY_MCP_SERVER_VERSION; - -/** Command that signs in to the active Sentry host. */ -export const LOGIN_COMMAND = MCP_SERVER_VERSION - ? "npx -y @sentry/mcp-server auth login" - : "sentry auth login"; - -/** - * Command that signs in and confirms a self-hosted host. The standalone - * `auth login` takes no `--url`, so the bundle points at the CLI, which - * shares the session. - */ -export const URL_LOGIN_COMMAND = MCP_SERVER_VERSION - ? "npx -y sentry auth login --url" - : "sentry auth login --url"; diff --git a/packages/cli/src/lib/login-host-guard.ts b/packages/cli/src/lib/login-host-guard.ts index 052f3e21e..2c7e0b478 100644 --- a/packages/cli/src/lib/login-host-guard.ts +++ b/packages/cli/src/lib/login-host-guard.ts @@ -21,7 +21,7 @@ import { getDefaultUrl } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { normalizeUserInputToOrigin } from "./sentry-urls.js"; import { isHostTrusted, isLoginTrustAnchorFor } from "./token-host.js"; -import { URL_LOGIN_COMMAND } from "./login-command.js"; +import { getUrlLoginCommand } from "./app-identity.js"; /** * Resolve the host a login without `--url` would target, using the same env @@ -92,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" + - ` ${URL_LOGIN_COMMAND} ${host}${tokenFlag}` + ` ${getUrlLoginCommand()} ${host}${tokenFlag}` ); } diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index 7d4b6e92e..1b127e4c2 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -8,7 +8,9 @@ import { refreshToken, } from "./db/auth.js"; import { getEnv } from "./env.js"; -import { AuthError, HostScopeError, ValidationError } from "./errors.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"; @@ -17,44 +19,39 @@ type McpTarget = { 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 }; } } @@ -244,27 +241,15 @@ export function resolveStaticMcpAccessToken( } /** - * Start the local stdio server without introducing a second auth flow. + * Run the local stdio server without introducing a second auth flow. * - * Resolves `true` once the server is running, or `false` when the invocation - * only printed output (`--help`, `--version`) and the process can exit. + * Resolves once the client disconnects. */ -export async function startMcpServer(args: string[]): Promise { - if (args[0] === "auth") { - throw new ValidationError( - "Use `sentry auth` to manage credentials for `sentry mcp`.", - ); - } - +export async function serveMcp(flags: McpFlags): Promise { const env = getEnv(); - const { parseMcpInvocation, serveMcpStdio } = await import("./mcp/server.js"); - const invocation = parseMcpInvocation(prepareMcpServerArgs(args), env); - if (invocation.kind === "output") { - process.stdout.write(`${invocation.text}\n`); - return false; - } - - const { config } = invocation; + const config = finalizeMcpConfig( + mergeMcpArgs(withConfiguredTarget(flags), parseMcpEnv(env)), + ); const staticToken = config.accessToken ? resolveStaticMcpAccessToken({ ...config, @@ -279,22 +264,22 @@ export async function startMcpServer(args: string[]): Promise { // overrides the target, or the token is an org token whose host claim // `resolveStaticMcpAccessToken` already matched against the target. if ( - !( - hasSentryTargetArg(args) || - (staticToken && parseSntrysClaim(staticToken)) - ) + !(hasSentryTarget(flags) || (staticToken && parseSntrysClaim(staticToken))) ) { const { assertRcUrlTrusted } = await import("./sentryclirc.js"); await assertRcUrlTrusted(process.cwd()); } + // 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"); if (staticToken) { - serveMcpStdio(config, { accessToken: staticToken, env }); - return true; + await serveMcpStdio(config, { accessToken: staticToken, env }); + return; } const { token, pin } = await resolveMcpCredentialWithLogin(config); - serveMcpStdio(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 @@ -303,5 +288,4 @@ export async function startMcpServer(args: string[]): Promise { (await resolveCliMcpCredential(config, { force: true, pin })).token, env, }); - return true; } diff --git a/packages/cli/src/lib/mcp/args.ts b/packages/cli/src/lib/mcp/args.ts index 2349fcfe7..7d31bc9b5 100644 --- a/packages/cli/src/lib/mcp/args.ts +++ b/packages/cli/src/lib/mcp/args.ts @@ -6,122 +6,80 @@ * over environment values. */ -import { parseArgs } from "node:util"; +/** `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 `sentry mcp` flags. */ -export type McpCliArgs = { +/** Values read from the environment. */ +export type McpEnvArgs = { accessToken?: string; - host?: string; - insecureHttp?: boolean; - url?: string; mcpUrl?: string; sentryDsn?: string; - openaiBaseUrl?: string; openaiModel?: string; - anthropicBaseUrl?: string; anthropicModel?: string; agentProvider?: string; skills?: string; - allSkills?: boolean; disableSkills?: string; - experimental?: boolean; - organizationSlug?: string; - projectSlug?: string; - help?: boolean; - version?: boolean; - unknownArgs: string[]; }; -/** Values read from the environment. */ -export type McpEnvArgs = { +/** Flags merged over environment values, before validation. */ +export type McpMergedArgs = { accessToken?: string; + host?: string; + insecureHttp: boolean; + url?: string; mcpUrl?: string; sentryDsn?: string; + openaiBaseUrl?: string; openaiModel?: string; + anthropicBaseUrl?: string; anthropicModel?: string; agentProvider?: string; - skills?: string; - disableSkills?: string; -}; - -/** Flags merged over environment values, before validation. */ -export type McpMergedArgs = Omit & { /** Whether `--agent-provider` (not the environment) chose the provider. */ agentProviderFromFlag: boolean; + skills?: string; + allSkills: boolean; + disableSkills?: string; + experimental: boolean; + organizationSlug?: string; + projectSlug?: string; }; -const OPTIONS = { - "access-token": { type: "string" }, - host: { type: "string" }, - "insecure-http": { type: "boolean" }, - url: { type: "string" }, - "mcp-url": { type: "string" }, - "sentry-dsn": { type: "string" }, - "openai-base-url": { type: "string" }, - "openai-model": { type: "string" }, - "anthropic-base-url": { type: "string" }, - "anthropic-model": { type: "string" }, - "agent-provider": { type: "string" }, - "organization-slug": { type: "string" }, - "project-slug": { type: "string" }, - skills: { type: "string" }, - "all-skills": { type: "boolean" }, - "disable-skills": { type: "string" }, - experimental: { type: "boolean" }, - help: { type: "boolean", short: "h" }, - version: { type: "boolean", short: "v" }, -} as const; - -/** `parseArgs` reports known short flags under their long name. */ -const KNOWN_OPTIONS = new Set(Object.keys(OPTIONS)); - -function stringValue(value: string | boolean | undefined): string | undefined { - return typeof value === "string" ? value : undefined; -} - -/** Parse `sentry mcp` flags, collecting unknown flags and positionals. */ -export function parseMcpArgv(argv: string[]): McpCliArgs { - const { values, tokens } = parseArgs({ - args: argv, - options: OPTIONS, - allowPositionals: true, - strict: false, - tokens: true, - }); - - const unknownArgs: string[] = []; - for (const token of tokens) { - if (token.kind === "option" && !KNOWN_OPTIONS.has(token.name)) { - unknownArgs.push(token.rawName); - } else if (token.kind === "positional") { - unknownArgs.push(token.value); - } - } - - return { - accessToken: stringValue(values["access-token"]), - host: stringValue(values.host), - insecureHttp: values["insecure-http"] === true, - url: stringValue(values.url), - mcpUrl: stringValue(values["mcp-url"]), - sentryDsn: stringValue(values["sentry-dsn"]), - openaiBaseUrl: stringValue(values["openai-base-url"]), - openaiModel: stringValue(values["openai-model"]), - anthropicBaseUrl: stringValue(values["anthropic-base-url"]), - anthropicModel: stringValue(values["anthropic-model"]), - agentProvider: stringValue(values["agent-provider"]), - organizationSlug: stringValue(values["organization-slug"]), - projectSlug: stringValue(values["project-slug"]), - skills: stringValue(values.skills), - allSkills: values["all-skills"] === true, - disableSkills: stringValue(values["disable-skills"]), - experimental: values.experimental === true, - help: values.help === true, - version: values.version === true, - unknownArgs, - }; -} - /** * Read MCP settings from the environment. * @@ -158,27 +116,26 @@ export function parseMcpEnv(env: NodeJS.ProcessEnv): McpEnvArgs { } /** Merge flags over environment values. */ -export function mergeMcpArgs(cli: McpCliArgs, env: McpEnvArgs): McpMergedArgs { +export function mergeMcpArgs(flags: McpFlags, env: McpEnvArgs): McpMergedArgs { return { - accessToken: cli.accessToken ?? env.accessToken, - url: cli.url, - host: cli.host, - insecureHttp: cli.insecureHttp === true, - mcpUrl: cli.mcpUrl ?? env.mcpUrl, - sentryDsn: cli.sentryDsn ?? env.sentryDsn, - openaiBaseUrl: cli.openaiBaseUrl, - openaiModel: cli.openaiModel ?? env.openaiModel, - anthropicBaseUrl: cli.anthropicBaseUrl, - anthropicModel: cli.anthropicModel ?? env.anthropicModel, - agentProvider: cli.agentProvider ?? env.agentProvider, - agentProviderFromFlag: cli.agentProvider !== undefined, + accessToken: flags["access-token"] ?? env.accessToken, + 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: cli.skills ?? (cli.allSkills ? undefined : env.skills), - allSkills: cli.allSkills === true, - disableSkills: cli.disableSkills ?? env.disableSkills, - experimental: cli.experimental === true, - organizationSlug: cli.organizationSlug, - projectSlug: cli.projectSlug, - unknownArgs: cli.unknownArgs, + 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/auth.ts b/packages/cli/src/lib/mcp/auth.ts deleted file mode 100644 index 59f02e10e..000000000 --- a/packages/cli/src/lib/mcp/auth.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * `sentry-mcp auth` for the standalone `@sentry/mcp-server` bundle. - * - * Only `login` is built in, so the package can authenticate without the CLI. - * Account management stays in `sentry auth`, which uses the same session. - */ - -import { assertAutoLoginHostTrusted } from "../auto-auth.js"; -import { ValidationError } from "../errors.js"; -import { runInteractiveLogin } from "../interactive-login.js"; - -/** Run `sentry-mcp auth [login]`. */ -export async function runMcpServerAuth(args: readonly string[]): Promise { - const [subcommand = "login", ...rest] = args; - if (subcommand !== "login" || rest.length > 0) { - throw new ValidationError( - `\`sentry-mcp auth ${args.join(" ")}\` is not supported. ` + - `Run \`npx -y sentry auth ${args.join(" ")}\` instead; it uses the same session.`, - ); - } - - // Never start an OAuth device flow against an unconfirmed self-hosted host. - assertAutoLoginHostTrusted(); - - const result = await runInteractiveLogin(); - if (!result) { - process.exitCode = 1; - return; - } - const identity = result.user?.email ?? result.user?.username; - process.stderr.write( - identity ? `Logged in as ${identity}.\n` : "Logged in.\n", - ); -} diff --git a/packages/cli/src/lib/mcp/config.ts b/packages/cli/src/lib/mcp/config.ts index 2ff38dd60..b784e6c4f 100644 --- a/packages/cli/src/lib/mcp/config.ts +++ b/packages/cli/src/lib/mcp/config.ts @@ -20,7 +20,6 @@ import { import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; import { ValidationError } from "../errors.js"; import type { McpMergedArgs } from "./args.js"; -import { MCP_COMMAND } from "./usage.js"; /** Embedded agent providers the MCP server can use. */ export type McpAgentProvider = @@ -164,11 +163,6 @@ function resolveAgentProvider(value?: string): McpAgentProvider | undefined { /** Validate merged settings and apply defaults. */ export function finalizeMcpConfig(input: McpMergedArgs): McpServerConfig { - if (input.unknownArgs.length > 0) { - throw new ValidationError( - `Invalid argument(s): ${input.unknownArgs.join(", ")}\nRun \`${MCP_COMMAND} --help\` for usage.`, - ); - } if (input.insecureHttp && input.url) { throw new ValidationError( "--insecure-http cannot be used with --url. Use --host for insecure self-hosted instances.", @@ -202,7 +196,7 @@ export function finalizeMcpConfig(input: McpMergedArgs): McpServerConfig { anthropicModel: input.anthropicModel, agentProvider: resolveAgentProvider(input.agentProvider), agentProviderFromFlag: input.agentProviderFromFlag, - experimental: input.experimental === true, + experimental: input.experimental, finalSkills, organizationSlug: input.organizationSlug, projectSlug: input.projectSlug, diff --git a/packages/cli/src/lib/mcp/run.ts b/packages/cli/src/lib/mcp/run.ts deleted file mode 100644 index 88432d705..000000000 --- a/packages/cli/src/lib/mcp/run.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Run the stdio MCP server and handle setup failures. - * - * Shared by `sentry mcp` and the standalone `@sentry/mcp-server` bundle. - */ - -import { formatError, getExitCode } from "../errors.js"; -import { startMcpServer } from "../mcp.js"; - -/** - * Release network resources after a finite MCP invocation, mirroring the - * teardown `runCli` applies to every other drained command. Never call this - * while a stdio server is running: it would close the server's dispatcher and - * arm a force-exit on macOS. - */ -export async function releaseProcessResources(): Promise { - const [{ scheduleForceExit }, { closeGlobalDispatcher }] = await Promise.all([ - import("../force-exit.js"), - import("../close-dispatcher.js"), - ]); - scheduleForceExit(); - await closeGlobalDispatcher(); -} - -export async function runMcpServer(args: string[]): Promise { - try { - if (await startMcpServer(args)) { - return; - } - } catch (mcpError) { - process.stderr.write(`Error: ${formatError(mcpError)}\n`); - process.exitCode = getExitCode(mcpError); - } - // Setup errors and `--help`/`--version` are terminal, unlike a running - // stdio server. - await releaseProcessResources(); -} diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 9a1080a59..9831116a9 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -1,7 +1,7 @@ /** * stdio MCP server host for `sentry mcp`. * - * Parses the server flags, configures the embedded agent provider and 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. * @@ -16,7 +16,6 @@ import { setProviderBaseUrls, } from "@sentry/mcp-core/internal/agents/provider-factory"; import { buildServer } from "@sentry/mcp-core/server"; -import { ALL_SKILLS } from "@sentry/mcp-core/skills"; import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; import { startStdio } from "@sentry/mcp-core/transports/stdio"; import type { ServerContext } from "@sentry/mcp-core/types"; @@ -29,18 +28,11 @@ import { vercelAIIntegration, zodErrorsIntegration, } from "@sentry/node"; -import { SENTRY_CLI_DSN } from "../constants.js"; -import { MCP_SERVER_VERSION } from "../login-command.js"; +import { getAppIdentity } from "../app-identity.js"; +import { CLI_VERSION, SENTRY_CLI_DSN } from "../constants.js"; import { isTelemetryEnabled } from "../telemetry.js"; import { redactTelemetryEnvelope } from "../telemetry/credential-redaction.js"; -import { mergeMcpArgs, parseMcpArgv, parseMcpEnv } from "./args.js"; -import { finalizeMcpConfig, type McpServerConfig } from "./config.js"; -import { buildMcpUsage, buildMcpVersion, MCP_VERSION } from "./usage.js"; - -/** What a `sentry mcp` invocation asks for. */ -export type McpInvocation = - | { kind: "output"; text: string } - | { kind: "serve"; config: McpServerConfig }; +import type { McpServerConfig } from "./config.js"; /** Credential wiring supplied by the CLI host. */ export type McpServeOptions = { @@ -55,24 +47,6 @@ export type McpServeOptions = { const SENTRY_FLUSH_TIMEOUT_MS = 5000; -/** Parse `sentry mcp` arguments into help/version output or a server config. */ -export function parseMcpInvocation( - args: string[], - env: NodeJS.ProcessEnv, -): McpInvocation { - const cli = parseMcpArgv(args); - if (cli.help) { - return { kind: "output", text: buildMcpUsage(ALL_SKILLS) }; - } - if (cli.version) { - return { kind: "output", text: buildMcpVersion() }; - } - return { - kind: "serve", - config: finalizeMcpConfig(mergeMcpArgs(cli, parseMcpEnv(env))), - }; -} - type ProviderMismatch = { configured: string; availableKey: string }; /** The configured provider's key is missing while another provider's is set. */ @@ -240,12 +214,11 @@ function configureAgentProvider( * @internal Exported for testing */ export function initMcpTelemetry(config: McpServerConfig): void { + const { name, packageName } = getAppIdentity(); const scopeContext = { - "app.server.version": MCP_VERSION, + "app.server.version": CLI_VERSION, // Tells `@sentry/mcp-server` installs apart from `sentry mcp`. - "app.server.distribution": MCP_SERVER_VERSION - ? "@sentry/mcp-server" - : "sentry mcp", + "app.server.distribution": name === "sentry" ? "sentry mcp" : packageName, "app.transport": "stdio", "app.upstream.host": config.sentryHost, "app.url.full": config.mcpUrl, @@ -271,7 +244,7 @@ export function initMcpTelemetry(config: McpServerConfig): void { }, }, // Always the running release; an env override would misreport it. - release: MCP_VERSION, + release: CLI_VERSION, // Same credential redaction as the CLI's own telemetry. transport: (transportOptions) => { const transport = makeNodeTransport(transportOptions); @@ -349,11 +322,11 @@ export function installProcessHandlers(): ( return exitAfterError; } -/** Serve the MCP tools over stdio until the client disconnects. */ -export function serveMcpStdio( +/** Serve the MCP tools over stdio. Resolves once the client disconnects. */ +export async function serveMcpStdio( config: McpServerConfig, options: McpServeOptions, -): void { +): Promise { configureAgentProvider(config, options.env); initMcpTelemetry(config); @@ -389,7 +362,14 @@ export function serveMcpStdio( experimentalMode: config.experimental, sdkVersion: "v2", }); - startStdio(server, context).catch((error: unknown) => { - exitAfterError(error, "Server error:"); - }); + 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/mcp/usage.ts b/packages/cli/src/lib/mcp/usage.ts deleted file mode 100644 index 86902c943..000000000 --- a/packages/cli/src/lib/mcp/usage.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** Help and version text for `sentry mcp` and `sentry-mcp`. */ - -import type { Skill } from "@sentry/mcp-core/skills"; -import { CLI_VERSION } from "../constants.js"; -import { LOGIN_COMMAND, MCP_SERVER_VERSION } from "../login-command.js"; - -/** The command that launched this server, for help and error text. */ -export const MCP_COMMAND = MCP_SERVER_VERSION ? "sentry-mcp" : "sentry mcp"; - -/** The release that is running: the package version or the CLI's. */ -export const MCP_VERSION = MCP_SERVER_VERSION ?? CLI_VERSION; - -/** Build the `--version` output. */ -export function buildMcpVersion(): string { - return `${MCP_COMMAND} ${MCP_VERSION}`; -} - -/** Build the `--help` text. */ -export function buildMcpUsage(allSkills: ReadonlyArray): string { - return `Usage: ${MCP_COMMAND} [--access-token=] [--host=] [--insecure-http] - -Authentication: - Uses the active Sentry CLI session by default. Authenticate with \`${LOGIN_COMMAND}\`. - --access-token Use this Sentry auth token instead of the CLI session - (never refreshed; org auth tokens must match the selected host) - -Common optional flags: - --host Change Sentry host (self-hosted) - --url Change Sentry URL (https only; use --host for http) - --insecure-http Use http:// for self-hosted --host values - --mcp-url MCP server URL reported in telemetry - --sentry-dsn Override DSN used for telemetry reporting - --experimental Enable forward-looking tool variants and experimental features - -h, --help Show this help - -v, --version Show the version - -Embedded agent configuration: - --agent-provider LLM provider: openai, azure-openai, anthropic, or openrouter (auto-detects from API keys) - --openai-base-url Override OpenAI API base URL - --openai-model Override OpenAI model (default: gpt-5) - --anthropic-base-url Override Anthropic API base URL - --anthropic-model Override Anthropic model (default: claude-sonnet-4-5) - -Session constraints: - --organization-slug Force all calls to an organization - --project-slug Optional project constraint - -Skill controls: - --skills Specify which skills to grant (default: active, non-deprecated skills; - seer is excluded by default on self-hosted hosts) - --all-skills Grant all active, non-deprecated skills (including seer on self-hosted) - --disable-skills Remove specific skills (e.g. --disable-skills=seer) - -Available skills: ${allSkills.join(", ")} - -Environment variables: - SENTRY_ACCESS_TOKEN Static Sentry token (alternative to --access-token) - SENTRY_HOST, SENTRY_URL Sentry instance (same as for other sentry commands) - MCP_URL MCP server URL reported in telemetry - SENTRY_DSN DSN used for telemetry reporting - OPENAI_API_KEY OpenAI API key for AI-powered search tools - OPENAI_MODEL OpenAI model override - ANTHROPIC_API_KEY Anthropic API key for AI-powered search tools - ANTHROPIC_MODEL Anthropic model override - OPENROUTER_API_KEY OpenRouter API key for AI-powered search tools - OPENROUTER_MODEL OpenRouter model (default: openai/gpt-5.6-luna) - OPENROUTER_REASONING_EFFORT OpenRouter reasoning effort: none|minimal|low|medium|high|xhigh (max→xhigh), or "" to omit (default: high) - EMBEDDED_AGENT_PROVIDER Provider override: openai, azure-openai, anthropic, or openrouter - MCP_SKILLS Specify which skills to grant (comma-separated) - MCP_DISABLE_SKILLS Disable specific skills (comma-separated) - -Examples: - ${MCP_COMMAND} - ${MCP_COMMAND} --all-skills - ${MCP_COMMAND} --skills=inspect,triage - ${MCP_COMMAND} --host=sentry.example.com - ${MCP_COMMAND} --host=sentry.internal:9000 --insecure-http - ${MCP_COMMAND} --access-token=TOKEN --host=sentry.example.com --skills=inspect,seer - ${MCP_COMMAND} --agent-provider=azure-openai --openai-base-url=https://example.openai.azure.com/openai/v1/ - ${MCP_COMMAND} --agent-provider=anthropic`; -} diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 3c06f8da4..454c7b712 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -38,7 +38,7 @@ import { import { logger } from "./logger.js"; import { normalizeHttpOrigin } from "./sentry-urls.js"; import { getActiveTokenHost } from "./token-host.js"; -import { LOGIN_COMMAND } from "./login-command.js"; +import { getLoginCommand } from "./app-identity.js"; /** * Get the Sentry instance URL for OAuth endpoints. @@ -396,7 +396,7 @@ async function attemptPoll( case "expired": return { status: "error", - message: `Device code expired. Please run '${LOGIN_COMMAND}' again.`, + message: `Device code expired. Please run '${getLoginCommand()}' again.`, }; case "denied": return { @@ -575,7 +575,7 @@ export async function refreshAccessToken( if (rejected) { throw new AuthError( "expired", - `Session expired because the refresh credential was rejected. Run '${LOGIN_COMMAND}' 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/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/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 index c9d41e98d..8f7850cc9 100644 --- a/packages/cli/src/mcp-server.ts +++ b/packages/cli/src/mcp-server.ts @@ -1,56 +1,37 @@ /** - * Entry point for the standalone `@sentry/mcp-server` bundle. + * Entry point for the `@sentry/mcp-server` bundle (`sentry-mcp`). * - * Runs the same server as `sentry mcp` without the rest of the CLI, so the - * package stays small. Built by `packages/mcp-server/script/bundle.ts`. + * 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 { redactCredentialText } from "./lib/credential-redaction.js"; -import { CliError, formatError, getExitCode } from "./lib/errors.js"; -import { preloadProjectContext } from "./lib/project-context.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(); -async function main(args: string[]): Promise { - try { - await preloadProjectContext(process.cwd()); - } catch (err) { - if (err instanceof CliError) { - process.stderr.write(`${formatError(err)}\n`); - process.exitCode = err.exitCode; - return; - } - // Project context is optional; a missing or unreadable rc file is fine. - } +// Before anything formats a hint: they name this package, not `sentry`. +setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); - if (args[0] === "auth") { - const [{ runMcpServerAuth }, { releaseProcessResources }] = - await Promise.all([ - import("./lib/mcp/auth.js"), - import("./lib/mcp/run.js"), - ]); - try { - await runMcpServerAuth(args.slice(1)); - } catch (err) { - process.stderr.write(`Error: ${formatError(err)}\n`); - process.exitCode = getExitCode(err); - } finally { - await releaseProcessResources(); - } - return; - } +// 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"; - const { runMcpServer } = await import("./lib/mcp/run.js"); - await runMcpServer(args); +// MCP clients and install checks run a bare `--version`; answer it without +// loading the application and the CLI telemetry it brings. +if (process.argv.length === 3 && process.argv[2] === "--version") { + process.stdout.write(`${CLI_VERSION}\n`); +} else { + startCli({ loadApp: () => import("./mcp-server-app.js") }).catch(() => { + process.exitCode = 1; + }); } - -main(process.argv.slice(2)).catch((err) => { - process.stderr.write(`Fatal: ${redactCredentialText(String(err))}\n`); - 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..3e0c9e545 --- /dev/null +++ b/packages/cli/src/sentry-cli.ts @@ -0,0 +1,27 @@ +/** + * The full `sentry` CLI: every command, plus shell completion. + * + * Run by `bin.ts` (standalone binaries) and by `index.ts`'s `_cli` (the npm + * package's `dist/bin.cjs`). 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"; +import { getEnv } from "./lib/env.js"; + +export const SENTRY_CLI: CliEntry = { + loadApp: () => import("./app.js"), + /** + * Fast-path: shell completion. + * + * Dispatched before importing the full CLI to avoid loading @sentry/node, + * @stricli/core, and other heavy dependencies. Only loads the lightweight + * completion engine and SQLite cache modules. + */ + async complete(completionArgs) { + // Disable telemetry so db/index.ts skips the @sentry/node lazy-require (~100ms) + getEnv().SENTRY_CLI_NO_TELEMETRY = "1"; + const { handleComplete } = await import("./lib/complete.js"); + await handleComplete(completionArgs); + }, +}; 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/mcp-auth.test.ts b/packages/cli/test/lib/mcp-auth.test.ts index 1566a5db7..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, resolveCliMcpCredential, - startMcpServer, + withConfiguredTarget, } from "../../src/lib/mcp.js"; import { resetHostScopingState, useEnvSandbox, useTestConfigDir, } from "../helpers.js"; +import { parseMcpFlags } from "./mcp/parse-flags.js"; useTestConfigDir("mcp-auth-"); useEnvSandbox([ @@ -104,57 +104,68 @@ describe("resolveCliMcpCredential", () => { }), ).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..723b5e99e 100644 --- a/packages/cli/test/lib/mcp-command.test.ts +++ b/packages/cli/test/lib/mcp-command.test.ts @@ -1,17 +1,191 @@ -import { expect, test } from "vitest"; +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 { 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); + }); +}); + +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, + }); + }); }); -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("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("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:"); + }); + + 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 77e48ba18..000000000 --- a/packages/cli/test/lib/mcp-run.test.ts +++ /dev/null @@ -1,75 +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(true); - 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 after an invocation that only prints output", async () => { - startMcpServer.mockResolvedValue(false); - const { runMcpCommand } = await import("../../src/cli.js"); - - await expect(runMcpCommand(["mcp", "--help"])).resolves.toBe(true); - - // `--help` and `--version` finish like any other drained command, so they - // need the same teardown `runCli` gives every other command. - expect(scheduleForceExit).toHaveBeenCalledTimes(1); - expect(closeGlobalDispatcher).toHaveBeenCalledTimes(1); - }); - - test("cleans up network resources when setup fails", async () => { - startMcpServer.mockRejectedValue(new Error("boom")); - const { runMcpCommand } = await import("../../src/cli.js"); - - 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 5ea6ccdaf..cdeed206a 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -25,6 +25,7 @@ import { useEnvSandbox, useTestConfigDir, } from "../helpers.js"; +import { parseMcpFlags } from "./mcp/parse-flags.js"; type Served = { config: McpServerConfig; options: McpServeOptions }; @@ -55,9 +56,11 @@ vi.mock("../../src/lib/db/auth.js", async (importOriginal) => { vi.mock("../../src/lib/mcp/server.js", async (importOriginal) => ({ ...(await importOriginal()), - serveMcpStdio: vi.fn((config: McpServerConfig, options: McpServeOptions) => { - served.calls.push({ config, options }); - }), + serveMcpStdio: vi.fn( + async (config: McpServerConfig, options: McpServeOptions) => { + served.calls.push({ config, options }); + }, + ), })); useTestConfigDir("mcp-start-"); @@ -81,9 +84,14 @@ afterEach(async () => { await resetHostScopingState(); }); +/** 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)); +} + async function start(args: string[]): Promise { - const { startMcpServer } = await import("../../src/lib/mcp.js"); - await expect(startMcpServer(args)).resolves.toBe(true); + await serve(args); expect(served.calls).toHaveLength(1); const [call] = served.calls; if (!call) { @@ -92,7 +100,7 @@ async function start(args: string[]): Promise { return call; } -describe("startMcpServer credential wiring", () => { +describe("serveMcp credential wiring", () => { test("serves with the CLI session token", async () => { setAuthToken("cli-session-token", undefined, undefined, { host: "https://sentry.io", @@ -117,7 +125,7 @@ describe("startMcpServer credential wiring", () => { }); }); -describe("startMcpServer account pinning", () => { +describe("serveMcp account pinning", () => { const originalFetch = globalThis.fetch; const refreshedWith: string[] = []; @@ -251,7 +259,7 @@ describe("startMcpServer account pinning", () => { }); }); -describe("startMcpServer static tokens", () => { +describe("serveMcp static tokens", () => { test("uses --access-token without a CLI session and never refreshes it", async () => { await clearAuth(); @@ -292,10 +300,9 @@ describe("startMcpServer static tokens", () => { }); test("rejects a malformed static token", async () => { - const { startMcpServer } = await import("../../src/lib/mcp.js"); - await expect( - startMcpServer(["--access-token=has a space"]), - ).rejects.toBeInstanceOf(MalformedAuthTokenError); + await expect(serve(["--access-token=has a space"])).rejects.toBeInstanceOf( + MalformedAuthTokenError, + ); expect(served.calls).toHaveLength(0); }); @@ -339,15 +346,14 @@ describe("startMcpServer static tokens", () => { org: "acme", }); - const { startMcpServer } = await import("../../src/lib/mcp.js"); await expect( - startMcpServer([`--access-token=${token}`, "--host=evil.example.com"]), + serve([`--access-token=${token}`, "--host=evil.example.com"]), ).rejects.toBeInstanceOf(HostScopeError); expect(served.calls).toHaveLength(0); }); }); -describe("startMcpServer .sentryclirc URLs", () => { +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-")); @@ -375,11 +381,10 @@ describe("startMcpServer .sentryclirc URLs", () => { await inRcDir("https://evil.example.com"); process.env.SENTRY_ACCESS_TOKEN = "env-static-token"; - const { startMcpServer } = await import("../../src/lib/mcp.js"); - await expect(startMcpServer([])).rejects.toBeInstanceOf(HostScopeError); - await expect( - startMcpServer(["--access-token=static-token"]), - ).rejects.toBeInstanceOf(HostScopeError); + await expect(serve([])).rejects.toBeInstanceOf(HostScopeError); + await expect(serve(["--access-token=static-token"])).rejects.toBeInstanceOf( + HostScopeError, + ); expect(served.calls).toHaveLength(0); }); @@ -425,10 +430,9 @@ describe("startMcpServer .sentryclirc URLs", () => { org: "acme", }); - const { startMcpServer } = await import("../../src/lib/mcp.js"); - await expect( - startMcpServer([`--access-token=${token}`]), - ).rejects.toBeInstanceOf(HostScopeError); + await expect(serve([`--access-token=${token}`])).rejects.toBeInstanceOf( + HostScopeError, + ); expect(served.calls).toHaveLength(0); }); @@ -442,22 +446,3 @@ describe("startMcpServer .sentryclirc URLs", () => { expect(config.sentryHost).toBe("sentry.example.com"); }); }); - -describe("startMcpServer output flags", () => { - test.each([["--help"], ["--version"]])( - "prints %s to stdout without serving", - async (flag) => { - const write = vi - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - const { startMcpServer } = await import("../../src/lib/mcp.js"); - - await expect(startMcpServer([flag])).resolves.toBe(false); - - expect(served.calls).toHaveLength(0); - expect(write).toHaveBeenCalledWith( - expect.stringMatching(/^(Usage: sentry mcp|sentry mcp \S+)/), - ); - }, - ); -}); diff --git a/packages/cli/test/lib/mcp/args.test.ts b/packages/cli/test/lib/mcp/args.test.ts index a6a9823b6..41761df53 100644 --- a/packages/cli/test/lib/mcp/args.test.ts +++ b/packages/cli/test/lib/mcp/args.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; import { mergeMcpArgs, - parseMcpArgv, parseMcpEnv, + splitInlineFlagValues, } from "../../../src/lib/mcp/args.js"; +import { parseMcpFlags } from "./parse-flags.js"; -describe("parseMcpArgv", () => { - it("parses known flags and short aliases", () => { - const parsed = parseMcpArgv([ +describe("sentry mcp flags", () => { + it("parses known flags", async () => { + const parsed = await parseMcpFlags([ "--access-token=tok", "--host=sentry.io", "--insecure-http", @@ -17,75 +18,72 @@ describe("parseMcpArgv", () => { "--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([]); + 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("parses skills flags", () => { - const parsed = parseMcpArgv([ - "--access-token=tok", - "--skills=inspect,triage", - ]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.skills).toBe("inspect,triage"); + it("defaults boolean flags to off", async () => { + expect(await parseMcpFlags([])).toMatchObject({ + "insecure-http": false, + "all-skills": false, + experimental: false, + }); }); - it("parses --disable-skills", () => { - const parsed = parseMcpArgv([ - "--access-token=tok", - "--disable-skills=seer", - ]); - expect(parsed.disableSkills).toBe("seer"); + it("parses --disable-skills", async () => { + const parsed = await parseMcpFlags(["--disable-skills=seer"]); + expect(parsed["disable-skills"]).toBe("seer"); }); - it("parses --all-skills", () => { - const parsed = parseMcpArgv(["--access-token=tok", "--all-skills"]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.allSkills).toBe(true); - }); + it.each(["azure-openai", "openrouter"])( + "parses --agent-provider=%s", + async (provider) => { + const parsed = await parseMcpFlags([`--agent-provider=${provider}`]); + expect(parsed["agent-provider"]).toBe(provider); + }, + ); - it("parses --insecure-http", () => { - const parsed = parseMcpArgv([ - "--access-token=tok", - "--host=sentry.internal:9000", - "--insecure-http", - ]); - expect(parsed.host).toBe("sentry.internal:9000"); - expect(parsed.insecureHttp).toBe(true); + it("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("parses --agent-provider=azure-openai", () => { - const parsed = parseMcpArgv([ - "--access-token=tok", - "--agent-provider=azure-openai", - ]); - expect(parsed.agentProvider).toBe("azure-openai"); + it("rejects unknown flags and positionals", async () => { + await expect(parseMcpFlags(["--unknown"])).rejects.toThrow(/unknown/); + await expect(parseMcpFlags(["extra"])).rejects.toThrow(/extra/); }); +}); - it("parses --agent-provider=openrouter", () => { - const parsed = parseMcpArgv([ - "--access-token=tok", - "--agent-provider=openrouter", +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=", ]); - expect(parsed.agentProvider).toBe("openrouter"); - }); - - it("collects unknown args", () => { - const parsed = parseMcpArgv(["--unknown", "--another=1"]); - expect(parsed.unknownArgs.length).toBeGreaterThan(0); }); }); @@ -113,13 +111,13 @@ describe("parseMcpEnv", () => { }); describe("mergeMcpArgs", () => { - it("applies precedence: CLI over env", () => { + it("applies precedence: flags over env", async () => { const env = parseMcpEnv({ SENTRY_ACCESS_TOKEN: "envtok", MCP_URL: "envmcp", SENTRY_DSN: "envdsn", }); - const cli = parseMcpArgv([ + const flags = await parseMcpFlags([ "--access-token=clitok", "--host=clihost", "--insecure-http", @@ -127,7 +125,7 @@ describe("mergeMcpArgs", () => { "--sentry-dsn=clidsn", "--openai-base-url=https://api.cli/v1", ]); - const merged = mergeMcpArgs(cli, env); + const merged = mergeMcpArgs(flags, env); expect(merged.accessToken).toBe("clitok"); expect(merged.host).toBe("clihost"); expect(merged.insecureHttp).toBe(true); @@ -136,80 +134,49 @@ describe("mergeMcpArgs", () => { expect(merged.openaiBaseUrl).toBe("https://api.cli/v1"); }); - it("applies precedence for skills: CLI over env", () => { - const env = parseMcpEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect", - }); - const cli = parseMcpArgv([ - "--access-token=clitok", - "--skills=inspect,triage", - ]); - const merged = mergeMcpArgs(cli, env); - expect(merged.skills).toBe("inspect,triage"); + it("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 CLI skills not provided", () => { - const env = parseMcpEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect,triage", - }); - const cli = parseMcpArgv(["--access-token=clitok"]); - const merged = mergeMcpArgs(cli, env); - expect(merged.skills).toBe("inspect,triage"); + it("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 CLI allSkills override env skills", () => { - const env = parseMcpEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_SKILLS: "inspect", - }); - const cli = parseMcpArgv(["--access-token=clitok", "--all-skills"]); - const merged = mergeMcpArgs(cli, env); + 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: CLI over env", () => { - const env = parseMcpEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_DISABLE_SKILLS: "docs", - }); - const cli = parseMcpArgv([ - "--access-token=clitok", - "--disable-skills=seer", - ]); - const merged = mergeMcpArgs(cli, env); - expect(merged.disableSkills).toBe("seer"); + it("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 CLI disableSkills not provided", () => { - const env = parseMcpEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - MCP_DISABLE_SKILLS: "seer", - }); - const cli = parseMcpArgv(["--access-token=clitok"]); - const merged = mergeMcpArgs(cli, env); - expect(merged.disableSkills).toBe("seer"); + it("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: CLI over env", () => { - const env = parseMcpEnv({ - SENTRY_ACCESS_TOKEN: "envtok", - EMBEDDED_AGENT_PROVIDER: "openai", - }); - const cli = parseMcpArgv([ - "--access-token=clitok", - "--agent-provider=azure-openai", - ]); - const merged = mergeMcpArgs(cli, env); - expect(merged.agentProvider).toBe("azure-openai"); + 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 standalone server flag", () => { - const parsed = parseMcpArgv([ + it("accepts every flag the server has always taken", async () => { + const flags = await parseMcpFlags([ "--access-token=tok", "--host=sentry.example.com", "--insecure-http", @@ -227,10 +194,8 @@ describe("@sentry/mcp-server flag parity", () => { "--all-skills", "--disable-skills=seer", "--experimental", - "--help", - "--version", ]); - expect(parsed).toEqual({ + expect(mergeMcpArgs(flags, {})).toEqual({ accessToken: "tok", host: "sentry.example.com", insecureHttp: true, @@ -242,33 +207,28 @@ describe("@sentry/mcp-server flag parity", () => { anthropicBaseUrl: "https://anthropic.example.com", anthropicModel: "claude-test", agentProvider: "anthropic", + agentProviderFromFlag: true, organizationSlug: "acme", projectSlug: "web", skills: "inspect", allSkills: true, disableSkills: "seer", experimental: true, - help: true, - version: true, - unknownArgs: [], }); }); - it("accepts space-separated flag values", () => { - const parsed = parseMcpArgv(["--access-token", "tok", "--host", "h.test"]); - expect(parsed.accessToken).toBe("tok"); - expect(parsed.host).toBe("h.test"); - }); - - it("reports unknown flags and positionals by their raw spelling", () => { - expect(parseMcpArgv(["--bogus=1", "-x", "extra"]).unknownArgs).toEqual([ - "--bogus", - "-x", - "extra", + it("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 standalone server environment variable", () => { + it("reads every server environment variable", () => { expect( parseMcpEnv({ SENTRY_ACCESS_TOKEN: "envtok", @@ -298,21 +258,21 @@ describe("@sentry/mcp-server flag parity", () => { ); }); - it("uses SENTRY_ACCESS_TOKEN when --access-token is absent", () => { + it("uses SENTRY_ACCESS_TOKEN when --access-token is absent", async () => { const merged = mergeMcpArgs( - parseMcpArgv([]), + await parseMcpFlags([]), parseMcpEnv({ SENTRY_ACCESS_TOKEN: "envtok" }), ); expect(merged.accessToken).toBe("envtok"); }); - it("records whether the agent provider came from the flag", () => { + it("records whether the agent provider came from the flag", async () => { const env = parseMcpEnv({ EMBEDDED_AGENT_PROVIDER: "openai" }); - expect(mergeMcpArgs(parseMcpArgv([]), env).agentProviderFromFlag).toBe( - false, - ); expect( - mergeMcpArgs(parseMcpArgv(["--agent-provider=openai"]), env) + mergeMcpArgs(await parseMcpFlags([]), env).agentProviderFromFlag, + ).toBe(false); + expect( + mergeMcpArgs(await parseMcpFlags(["--agent-provider=openai"]), env) .agentProviderFromFlag, ).toBe(true); }); diff --git a/packages/cli/test/lib/mcp/auth.test.ts b/packages/cli/test/lib/mcp/auth.test.ts deleted file mode 100644 index cb790d2cb..000000000 --- a/packages/cli/test/lib/mcp/auth.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { ValidationError } from "../../../src/lib/errors.js"; - -const assertAutoLoginHostTrusted = vi.fn(); -const runInteractiveLogin = vi.fn(); - -vi.mock("../../../src/lib/auto-auth.js", () => ({ - assertAutoLoginHostTrusted: () => assertAutoLoginHostTrusted(), -})); -vi.mock("../../../src/lib/interactive-login.js", () => ({ - runInteractiveLogin: (...args: unknown[]) => runInteractiveLogin(...args), -})); - -const { runMcpServerAuth } = await import("../../../src/lib/mcp/auth.js"); - -const originalExitCode = process.exitCode; -let stderr: string; - -beforeEach(() => { - assertAutoLoginHostTrusted.mockReset(); - runInteractiveLogin.mockReset(); - stderr = ""; - vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { - stderr += String(chunk); - return true; - }); -}); - -afterEach(() => { - vi.restoreAllMocks(); - process.exitCode = originalExitCode; -}); - -describe("runMcpServerAuth", () => { - test.each([[[]], [["login"]]])("logs in for %j", async (args) => { - runInteractiveLogin.mockResolvedValue({ - method: "oauth", - configPath: "/tmp/cli.db", - user: { email: "user@example.com" }, - }); - - await runMcpServerAuth(args); - - expect(assertAutoLoginHostTrusted).toHaveBeenCalledTimes(1); - expect(runInteractiveLogin).toHaveBeenCalledTimes(1); - expect(stderr).toBe("Logged in as user@example.com.\n"); - expect(process.exitCode).toBe(originalExitCode); - }); - - test("checks the host before starting the device flow", async () => { - assertAutoLoginHostTrusted.mockImplementation(() => { - throw new ValidationError("untrusted host"); - }); - - await expect(runMcpServerAuth(["login"])).rejects.toThrow("untrusted host"); - expect(runInteractiveLogin).not.toHaveBeenCalled(); - }); - - test("fails when the login does not complete", async () => { - runInteractiveLogin.mockResolvedValue(null); - - await runMcpServerAuth(["login"]); - - expect(process.exitCode).toBe(1); - }); - - test.each([[["status"]], [["logout"]], [["login", "--url=x"]]])( - "points %j to the CLI", - async (args) => { - await expect(runMcpServerAuth(args)).rejects.toThrow( - `npx -y sentry auth ${args.join(" ")}`, - ); - expect(runInteractiveLogin).not.toHaveBeenCalled(); - }, - ); -}); diff --git a/packages/cli/test/lib/mcp/config.test.ts b/packages/cli/test/lib/mcp/config.test.ts index 5ff1c93ad..90226860c 100644 --- a/packages/cli/test/lib/mcp/config.test.ts +++ b/packages/cli/test/lib/mcp/config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +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"; @@ -6,7 +6,6 @@ import { ValidationError } from "../../../src/lib/errors.js"; function finalize(input: Partial) { return finalizeMcpConfig({ agentProviderFromFlag: false, - unknownArgs: [], ...input, }); } @@ -21,7 +20,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", url: "https://sentry.example.com", - unknownArgs: [], }); expect(cfg.sentryHost).toBe("sentry.example.com"); expect(cfg.sentryProtocol).toBe("https"); @@ -32,7 +30,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", host: "sentry.internal:9000", insecureHttp: true, - unknownArgs: [], }); expect(cfg.sentryHost).toBe("sentry.internal:9000"); expect(cfg.sentryProtocol).toBe("http"); @@ -42,7 +39,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", openaiBaseUrl: "https://api.proxy.example/v1", - unknownArgs: [], }); expect(cfg.openaiBaseUrl).toBe( new URL("https://api.proxy.example/v1").toString(), @@ -54,7 +50,6 @@ describe("finalizeMcpConfig", () => { finalize({ accessToken: "tok", openaiBaseUrl: "ftp://example.com", - unknownArgs: [], }), ).toThrow(/OPENAI base URL must use http or https scheme/); }); @@ -63,7 +58,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", agentProvider: "azure-openai", - unknownArgs: [], }); expect(cfg.agentProvider).toBe("azure-openai"); }); @@ -72,7 +66,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", agentProvider: "openrouter", - unknownArgs: [], }); expect(cfg.agentProvider).toBe("openrouter"); }); @@ -82,15 +75,14 @@ describe("finalizeMcpConfig", () => { 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({ accessToken: "tok", url: "http://bad" })).toThrow( + /must be a full HTTPS URL/, + ); }); it("throws when --insecure-http is used with --url", () => { @@ -99,7 +91,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", url: "https://sentry.example.com", insecureHttp: true, - unknownArgs: [], }), ).toThrow(/cannot be used with --url/); }); @@ -113,7 +104,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", url: "http://sentry.internal:9000", insecureHttp: true, - unknownArgs: [], }), ).toThrow(/cannot be used with --url/); }); @@ -126,7 +116,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", host, insecureHttp: true, - unknownArgs: [], }), ).toThrow(/only supported for self-hosted Sentry hosts/); }, @@ -138,7 +127,6 @@ describe("finalizeMcpConfig", () => { finalize({ accessToken: "tok", skills: "invalid-skill", - unknownArgs: [], }), ).toThrow(/Invalid skills provided: invalid-skill/); }); @@ -148,7 +136,6 @@ describe("finalizeMcpConfig", () => { finalize({ accessToken: "tok", skills: "inspect,invalid1,triage,invalid2", - unknownArgs: [], }), ).toThrow(/Invalid skills provided: invalid1, invalid2/); }); @@ -157,7 +144,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", skills: "inspect,triage", - unknownArgs: [], }); expect(cfg.finalSkills.has("inspect")).toBe(true); expect(cfg.finalSkills.has("triage")).toBe(true); @@ -170,7 +156,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", skills: "docs", - unknownArgs: [], }); expect(cfg.finalSkills).toEqual(new Set(["docs"])); }); @@ -180,7 +165,6 @@ describe("finalizeMcpConfig", () => { finalize({ accessToken: "tok", skills: "preprod", - unknownArgs: [], }), ).toThrow(/Invalid skills provided: preprod/); }); @@ -190,7 +174,6 @@ describe("finalizeMcpConfig", () => { finalize({ accessToken: "tok", skills: "invalid1,invalid2", - unknownArgs: [], }), ).toThrow(/Invalid skills provided/); }); @@ -198,7 +181,6 @@ describe("finalizeMcpConfig", () => { it("grants all active skills when no skills specified", () => { const cfg = finalize({ accessToken: "tok", - unknownArgs: [], }); expect(cfg.finalSkills.size).toBe(4); expect(cfg.finalSkills.has("inspect")).toBe(true); @@ -213,7 +195,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", allSkills: true, - unknownArgs: [], }); expect(cfg.finalSkills.size).toBe(4); expect(cfg.finalSkills.has("inspect")).toBe(true); @@ -229,7 +210,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", host: "us.sentry.io", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(true); }); @@ -238,7 +218,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", host: "sentry.example.com", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.has("inspect")).toBe(true); @@ -251,7 +230,6 @@ describe("finalizeMcpConfig", () => { 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); @@ -262,7 +240,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", host: "sentry.example.com", skills: "inspect,seer", - unknownArgs: [], }); expect(cfg.finalSkills).toEqual(new Set(["inspect", "seer"])); }); @@ -272,7 +249,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", host: "sentry.example.com", allSkills: true, - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(true); expect(cfg.finalSkills.size).toBe(4); @@ -283,7 +259,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", host: "sentry.example.com", disableSkills: "seer", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.size).toBe(3); @@ -295,7 +270,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", allSkills: true, skills: "inspect", - unknownArgs: [], }), ).toThrow(/--all-skills cannot be combined with --skills/); }); @@ -305,7 +279,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", disableSkills: "seer", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.size).toBe(3); @@ -321,7 +294,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", skills: "inspect,triage,seer", disableSkills: "seer", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.size).toBe(2); @@ -334,7 +306,6 @@ describe("finalizeMcpConfig", () => { finalize({ accessToken: "tok", disableSkills: "invalid-skill", - unknownArgs: [], }), ).toThrow(/--disable-skills provided: invalid-skill/); }); @@ -345,7 +316,6 @@ describe("finalizeMcpConfig", () => { accessToken: "tok", skills: "seer", disableSkills: "seer", - unknownArgs: [], }), ).toThrow(/All skills have been disabled/); }); @@ -354,7 +324,6 @@ describe("finalizeMcpConfig", () => { const cfg = finalize({ accessToken: "tok", disableSkills: "seer,docs", - unknownArgs: [], }); expect(cfg.finalSkills.has("seer")).toBe(false); expect(cfg.finalSkills.has("docs")).toBe(false); @@ -366,37 +335,12 @@ describe("finalizeMcpConfig", () => { 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("rejects unknown arguments with a pointer to --help", () => { - expect(() => finalize({ unknownArgs: ["--bogus"] })).toThrow( - /Invalid argument\(s\): --bogus\nRun `sentry mcp --help`/, - ); - }); - - it("points @sentry/mcp-server installs at their own --help", async () => { - vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); - vi.resetModules(); - try { - const { finalizeMcpConfig: finalizeBundled } = - await import("../../../src/lib/mcp/config.js"); - expect(() => - finalizeBundled({ - agentProviderFromFlag: false, - unknownArgs: ["--bogus"], - }), - ).toThrow(/Run `sentry-mcp --help`/); - } finally { - vi.unstubAllGlobals(); - vi.resetModules(); - } - }); - it("reports invalid input as a CLI validation error", () => { expect(() => finalize({ url: "http://bad" })).toThrow(ValidationError); expect(() => finalize({ skills: "nope" })).toThrow(ValidationError); 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/telemetry.test.ts b/packages/cli/test/lib/mcp/telemetry.test.ts index 1cc0ada2d..4201b5a6c 100644 --- a/packages/cli/test/lib/mcp/telemetry.test.ts +++ b/packages/cli/test/lib/mcp/telemetry.test.ts @@ -84,15 +84,15 @@ describe("initMcpTelemetry", () => { }); test("tags @sentry/mcp-server installs", async () => { - vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); - vi.resetModules(); + 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": "1.2.3", + "app.server.version": CLI_VERSION, }, }); - expect(options.release).toBe("1.2.3"); + expect(options.release).toBe(CLI_VERSION); }); }); diff --git a/packages/cli/test/lib/mcp/usage-standalone.test.ts b/packages/cli/test/lib/mcp/usage-standalone.test.ts deleted file mode 100644 index c6135930c..000000000 --- a/packages/cli/test/lib/mcp/usage-standalone.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; - -afterEach(() => { - vi.unstubAllGlobals(); - vi.resetModules(); -}); - -describe("standalone @sentry/mcp-server bundle", () => { - test("names `sentry-mcp` and reports the package version", async () => { - vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); - vi.resetModules(); - const { buildMcpUsage, buildMcpVersion } = - await import("../../../src/lib/mcp/usage.js"); - - expect(buildMcpVersion()).toBe("sentry-mcp 1.2.3"); - const usage = buildMcpUsage(["inspect"]); - expect(usage).toMatch(/^Usage: sentry-mcp /); - expect(usage).toContain("`npx -y @sentry/mcp-server auth login`"); - expect(usage).not.toContain("sentry mcp"); - }); - - test("auth errors name the package's own login command", async () => { - vi.stubGlobal("SENTRY_MCP_SERVER_VERSION", "1.2.3"); - vi.resetModules(); - const { AuthError } = await import("../../../src/lib/errors.js"); - - expect(new AuthError("not_authenticated").message).toBe( - "Not authenticated. Run 'npx -y @sentry/mcp-server auth login' first.", - ); - }); -}); diff --git a/packages/cli/test/lib/mcp/usage.test.ts b/packages/cli/test/lib/mcp/usage.test.ts deleted file mode 100644 index f4ab04146..000000000 --- a/packages/cli/test/lib/mcp/usage.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ALL_SKILLS } from "@sentry/mcp-core/skills"; -import { describe, expect, it } from "vitest"; -import { CLI_VERSION } from "../../../src/lib/constants.js"; -import { parseMcpInvocation } from "../../../src/lib/mcp/server.js"; -import { buildMcpUsage } from "../../../src/lib/mcp/usage.js"; - -describe("buildMcpUsage", () => { - it("documents CLI session auth and the static token override", () => { - const usage = buildMcpUsage(["inspect"]); - - expect(usage).toContain("Uses the active Sentry CLI session"); - expect(usage).toContain("sentry auth login"); - expect(usage).toContain("--access-token "); - expect(usage).toContain("SENTRY_ACCESS_TOKEN"); - expect(usage).toContain("Available skills: inspect"); - expect(usage).not.toContain("device code"); - expect(usage).not.toContain("auth [login|logout|status]"); - }); - - it("lists every server flag", () => { - const usage = buildMcpUsage(ALL_SKILLS); - for (const flag of [ - "--access-token", - "--host", - "--url", - "--insecure-http", - "--mcp-url", - "--sentry-dsn", - "--experimental", - "--help", - "--version", - "--agent-provider", - "--openai-base-url", - "--openai-model", - "--anthropic-base-url", - "--anthropic-model", - "--organization-slug", - "--project-slug", - "--skills", - "--all-skills", - "--disable-skills", - ]) { - expect(usage).toContain(flag); - } - }); -}); - -describe("parseMcpInvocation", () => { - it.each([["--help"], ["-h"]])("prints usage for %s", (flag) => { - expect(parseMcpInvocation([flag], {})).toEqual({ - kind: "output", - text: buildMcpUsage(ALL_SKILLS), - }); - }); - - it.each([["--version"], ["-v"]])("prints the CLI version for %s", (flag) => { - expect(parseMcpInvocation([flag], {})).toEqual({ - kind: "output", - text: `sentry mcp ${CLI_VERSION}`, - }); - }); - - it("prints help even when other arguments are invalid", () => { - expect(parseMcpInvocation(["--bogus", "--help"], {}).kind).toBe("output"); - }); - - it("builds a server config from flags and the environment", () => { - const invocation = parseMcpInvocation( - ["--host=sentry.example.com", "--skills=inspect"], - { SENTRY_ACCESS_TOKEN: "envtok" }, - ); - expect(invocation).toMatchObject({ - kind: "serve", - config: { - accessToken: "envtok", - sentryHost: "sentry.example.com", - sentryProtocol: "https", - finalSkills: new Set(["inspect"]), - }, - }); - }); - - it("rejects unknown arguments", () => { - expect(() => parseMcpInvocation(["--bogus"], {})).toThrow( - "Invalid argument(s): --bogus", - ); - }); -}); diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 803928575..f20e7c8bd 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -18,18 +18,22 @@ Requires Node.js 22.15 or later. with the [`sentry` CLI](https://cli.sentry.dev), so an existing `sentry auth login` works too. - `--access-token` / `SENTRY_ACCESS_TOKEN` passes a static token instead. -- Each server process uses one account. Log out, check status, or switch - accounts with `npx -y sentry auth logout|status|switch`. +- 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 -Run `npx -y @sentry/mcp-server --help` for the flags and environment +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, pass `--host` or `--url`, or set `SENTRY_HOST` / `SENTRY_URL`. ## Development -The source lives in `packages/cli` so the server shares the CLI's auth. -`pnpm run bundle` builds `dist/sentry-mcp.cjs` from -`packages/cli/src/mcp-server.ts`, and `pnpm run test:bundle` smoke-tests the +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/script/bundle.ts b/packages/mcp-server/script/bundle.ts index 66cb52c06..d2d73c4cf 100644 --- a/packages/mcp-server/script/bundle.ts +++ b/packages/mcp-server/script/bundle.ts @@ -2,17 +2,19 @@ /** * Bundle the CLI's `src/mcp-server.ts` into this package's `dist/sentry-mcp.cjs`. * - * The package runs the same server as `sentry mcp` but ships only what the - * server needs: the MCP tools, the API client, and the CLI's auth. The build - * fails if anything on {@link FORBIDDEN_INPUTS} reaches the bundle or the - * output grows past {@link MAX_BUNDLE_BYTES}, so CLI-only code cannot creep in - * through a new import. + * 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 { textImportPlugin } from "../../cli/script/text-import-plugin.js"; const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -20,60 +22,45 @@ const CLI_ROOT = resolve(PACKAGE_ROOT, "../cli"); const OUTFILE = resolve(PACKAGE_ROOT, "dist/sentry-mcp.cjs"); /** - * Current output is about 3.6 MB. The budget leaves room for new tools without - * letting a heavy CLI dependency slip in unnoticed. + * Output is 3,689,911 bytes (3.52 MiB) as of the CLI-runner build. The budget + * leaves about 240 KB for new tools and commands, not enough for a heavy CLI + * dependency to slip in unnoticed. */ -const MAX_BUNDLE_BYTES = 4.5 * 1024 * 1024; +const MAX_BUNDLE_BYTES = 3.75 * 1024 * 1024; /** - * The login flow's only markdown use is two inline hints, so the bundle swaps - * the renderer for a plain-text version. esbuild fails the build if the swap - * lacks an export that some importer needs. - */ -const MARKDOWN_MODULE = resolve(CLI_ROOT, "src/lib/formatters/markdown.ts"); -const PLAIN_MARKDOWN_MODULE = resolve( - CLI_ROOT, - "src/lib/formatters/markdown-plain.ts", -); - -/** - * Inputs that serve the CLI but not the MCP server: CLI commands, markdown - * rendering, agent skills, shell completions, terminal images, the Ink UI, - * `sentry init`, build/sourcemap/debug-file processing, and the SQLite WASM - * fallback for Node.js < 22.15. + * 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, `sentry init`, build/sourcemap/debug-file processing, the + * SQLite WASM fallback for Node.js < 22.15, and MCP SDK v1 (stdio runs on v2). */ const FORBIDDEN_INPUTS: ReadonlyArray = [ - /[\\/]packages[\\/]cli[\\/]src[\\/](?:app\.ts|commands[\\/])/, - /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:agent-skills|banner|complete|completion-target|completions|kitty-image|sixel|sixel-image)\.ts$/, - /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/]formatters[\\/](?:markdown|pixel-canvas|sixel-dashboard|sixel-timeseries)\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/](?:app\.ts$|commands[\\/](?!auth[\\/]|help\.ts$|mcp\.ts$))/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:agent-skills|complete|completions)\.ts$/, + /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/]formatters[\\/]sixel-dashboard\.ts$/, /[\\/]packages[\\/]cli[\\/]src[\\/]lib[\\/](?:bash-hook|build|dif|init|react-native|snapshots|sourcemap|wasm)[\\/]/, - /[\\/]node_modules[\\/](?:cli-highlight|highlight\.js|marked|parse5|ink|ink-spinner|react|react-reconciler|yoga-layout|@sentry[\\/]symbolic|node-sqlite3-wasm)[\\/]/, + /[\\/]node_modules[\\/](?:cli-highlight|highlight\.js|parse5|ink|ink-spinner|react|react-reconciler|yoga-layout|@sentry[\\/]symbolic|node-sqlite3-wasm)[\\/]/, + /[\\/]node_modules[\\/](?:zod[\\/]v3|@modelcontextprotocol[\\/]sdk[\\/]dist[\\/]\w+[\\/]server[\\/]mcp\.js)/, ]; /** The bundle requires native `node:sqlite`, which Node.js 22.15 unflagged. */ const NODE_VERSION_CHECK = `{let v=process.versions.node.split(".").map(Number);if(v[0]<22||(v[0]===22&&v[1]<15)){console.error("Error: @sentry/mcp-server requires Node.js 22.15 or later (found "+process.version+").");process.exit(1)}}`; -const plainMarkdownPlugin: Plugin = { - name: "plain-markdown", - setup(b) { - b.onResolve({ filter: /[\\/]markdown\.js$/ }, (args) => - resolve(args.resolveDir, args.path.replace(/\.js$/, ".ts")) === - MARKDOWN_MODULE - ? { path: PLAIN_MARKDOWN_MODULE } - : undefined, - ); - }, -}; - /** * 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. + * + * MCP SDK v1's `McpServer` and Zod 3 are stubbed too. mcp-core still imports + * v1 for `buildServer()`'s default, but stdio asks for v2. `zod/v3` is only + * imported by v1 and by `@ai-sdk/provider-utils` code that never runs for + * Zod 4 schemas. The CLI's own bundles keep both (`@mastra/schema-compat` + * uses Zod 3). */ const STUBBED_MODULES = - /^(?:node-sqlite3-wasm|@sentry\/symbolic\/symbolic_bg\.wasm|ink|ink-spinner|react|react-reconciler|react-devtools-core|yoga-layout)(?:\/.*)?$/; + /^(?:node-sqlite3-wasm|@sentry\/symbolic\/symbolic_bg\.wasm|ink|ink-spinner|react|react-reconciler|react-devtools-core|yoga-layout|@modelcontextprotocol\/sdk\/server\/mcp\.js|zod\/v3)(?:\/.*)?$/; const stubModulesPlugin: Plugin = { name: "stub-cli-only-modules", @@ -131,7 +118,6 @@ const result = await build({ inject: ["./script/import-meta-url.js"], define: { SENTRY_CLI_VERSION: JSON.stringify(cliPkg.version), - SENTRY_MCP_SERVER_VERSION: JSON.stringify(pkg.version), // Without it, the CLI falls back to its committed default client ID. ...(process.env.SENTRY_CLIENT_ID ? { SENTRY_CLIENT_ID_BUILD: JSON.stringify(process.env.SENTRY_CLIENT_ID) } @@ -140,15 +126,21 @@ const result = await build({ // 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, - plainMarkdownPlugin, textImportPlugin, requireAliasPlugin, + zodEnglishLocalesPlugin, ], }); @@ -170,7 +162,7 @@ const bytes = Object.values(result.metafile.outputs).reduce( ); if (bytes > MAX_BUNDLE_BYTES) { console.error( - `\nError: the @sentry/mcp-server bundle is ${(bytes / 1024 / 1024).toFixed(2)} MB, over the ${(MAX_BUNDLE_BYTES / 1024 / 1024).toFixed(1)} MB budget.\n`, + `\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); } diff --git a/packages/mcp-server/test/bundle.test.mjs b/packages/mcp-server/test/bundle.test.mjs index 0408376ba..5889222d1 100644 --- a/packages/mcp-server/test/bundle.test.mjs +++ b/packages/mcp-server/test/bundle.test.mjs @@ -55,14 +55,28 @@ test("the bundle requires only Node.js built-ins", () => { test("--version prints the package version", () => { const result = run(["--version"]); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout.trim(), `sentry-mcp ${version}`); + assert.equal(result.stdout.trim(), version); }); -test("--help names the standalone command", () => { +test("--help lists only mcp, auth, and help", () => { const result = run(["--help"]); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /^Usage: sentry-mcp /); - assert.match(result.stdout, /`npx -y @sentry\/mcp-server auth login`/); + 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 () => { @@ -82,18 +96,33 @@ test("a client closing stdout early is a clean exit, not a crash", async () => { test("without credentials, points to its own auth login", () => { const result = run([]); assert.notEqual(result.status, 0); - assert.match(result.stderr, /npx -y @sentry\/mcp-server auth login/); - assert.doesNotMatch(result.stderr, /'sentry auth login'/); + assert.match(result.stderr, /'sentry-mcp auth login'/); }); -test("auth subcommands other than login point to the CLI", () => { +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, /npx -y sentry auth status/); + 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/); }); test("serves MCP over stdio with a static token", async () => { - const child = spawn(process.execPath, [bin], { + // `--sentry-dsn=` (no telemetry) is a form MCP configs have always used. + const child = spawn(process.execPath, [bin, "--sentry-dsn="], { env: { ...env, SENTRY_ACCESS_TOKEN: "sntryu_test" }, stdio: ["pipe", "pipe", "pipe"], }); @@ -139,6 +168,10 @@ test("serves MCP over stdio with a static token", async () => { 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); } finally { child.kill(); } From 7a2f0a2ab0e72ec82653efdcce28f553c1679df2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:38:48 +0000 Subject: [PATCH 27/29] docs(mcp): Name the package's own auth commands in stdio-auth Co-Authored-By: BYK --- docs/operations/stdio-auth.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index ac2451120..af88bc3a0 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -28,8 +28,7 @@ In order of precedence: 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: `sentry auth login`, `sentry-mcp auth login`, or -`npx -y @sentry/mcp-server auth login` under npx +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 the host with `--host`/`--url` or @@ -50,7 +49,7 @@ and refused when it does not. The credential store can hold several accounts, but each server process uses one: the account resolved at startup. With `SENTRY_HOST`/`SENTRY_URL` set, that is a stored account on that server; otherwise it is the active account -(`sentry auth switch`). Refreshes after a 401 stay on that account, and tool +(`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 From 69e65252e65e36b84b051cc35107377e5f109490 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:45:16 +0000 Subject: [PATCH 28/29] fix(mcp-server): Keep a lone -v printing the version The old package treated `-v` as `--version`. In the unified build the CLI's global flags own `-v` as `--verbose`, so `sentry-mcp -v` started the server instead. Answer a lone `-v` from the same fast path as `--version`. Co-Authored-By: BYK --- docs/releases/stdio.md | 3 ++- docs/testing/stdio.md | 2 +- packages/cli/src/mcp-server.ts | 6 ++++-- packages/mcp-server/test/bundle.test.mjs | 12 +++++++----- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index 6b60e978d..f7d8d46c4 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -38,7 +38,8 @@ The package is a single file with no runtime dependencies. 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` returns before the application loads. + 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. The bundle stubs SDK v1's `McpServer`, which `buildServer()` still imports for its default, and `zod/v3`, which only v1 diff --git a/docs/testing/stdio.md b/docs/testing/stdio.md index 509ef9416..23dd99cde 100644 --- a/docs/testing/stdio.md +++ b/docs/testing/stdio.md @@ -381,7 +381,7 @@ Run `pnpm --silent --dir packages/cli run cli mcp --help` for the full list. # Help --help # Show all options ---version # Show version +--version, -v # Show version (-v alone; elsewhere it is --verbose) ``` ### Environment Variables diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts index 8f7850cc9..77c7931a4 100644 --- a/packages/cli/src/mcp-server.ts +++ b/packages/cli/src/mcp-server.ts @@ -27,8 +27,10 @@ setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); 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. -if (process.argv.length === 3 && process.argv[2] === "--version") { +// 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(() => { diff --git a/packages/mcp-server/test/bundle.test.mjs b/packages/mcp-server/test/bundle.test.mjs index 5889222d1..2101649af 100644 --- a/packages/mcp-server/test/bundle.test.mjs +++ b/packages/mcp-server/test/bundle.test.mjs @@ -52,11 +52,13 @@ test("the bundle requires only Node.js built-ins", () => { assert.deepEqual([...new Set(external)], []); }); -test("--version prints the package version", () => { - const result = run(["--version"]); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout.trim(), version); -}); +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"]); From 9977bb0c72861e91f2a4d5634ded297d6a90fabf Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:53:57 +0000 Subject: [PATCH 29/29] fix(cli): Keep mcp-core out of the command tree `commands/mcp.ts` imported `@sentry/mcp-core/skills` for its help text, so loading any command needed mcp-core's build output. Docs generation (`sentry-cli-docs`, cli-build's docs job) and a fresh `pnpm run cli` load every command without building mcp-core, and failed. Copy the skill names into the command and guard the copy with a test. Co-Authored-By: BYK --- packages/cli/src/commands/mcp.ts | 16 ++++++++++++++-- packages/cli/test/lib/mcp-command.test.ts | 8 +++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 509baecd9..b629ef95b 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -7,11 +7,23 @@ * when the server starts, not for `--help`. */ -import { ALL_SKILLS } from "@sentry/mcp-core/skills"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.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; @@ -49,7 +61,7 @@ export const mcpCommand = buildCommand({ " 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: ${ALL_SKILLS.join(", ")}`, + `Available skills: ${MCP_SKILL_NAMES.join(", ")}`, examples: [ { description: "Start the server with the current CLI login", diff --git a/packages/cli/test/lib/mcp-command.test.ts b/packages/cli/test/lib/mcp-command.test.ts index 723b5e99e..5ff849109 100644 --- a/packages/cli/test/lib/mcp-command.test.ts +++ b/packages/cli/test/lib/mcp-command.test.ts @@ -1,6 +1,8 @@ +import { ALL_SKILLS } from "@sentry/mcp-core/skills"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { routes } from "../../src/app.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"; @@ -177,7 +179,11 @@ describe("running `mcp`", () => { expect(stdout).toContain(flag); } expect(stdout).toContain("SENTRY_ACCESS_TOKEN"); - expect(stdout).toContain("Available skills:"); + 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 () => {