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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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.