diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml index 039103a2b..cd6a29b18 100644 --- a/.github/workflows/merge-jobs.yml +++ b/.github/workflows/merge-jobs.yml @@ -34,8 +34,7 @@ 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: the stdio proxy to the hosted MCP. - 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..143c136c4 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/ # stdio proxy to the hosted MCP (@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 — How `sentry mcp` and the `@sentry/mcp-server` proxy authenticate - 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` release, registry, and the `@sentry/mcp-server` shim +- docs/releases/stdio.md — `sentry mcp` release, registry, and the `@sentry/mcp-server` proxy - docs/releases/cloudflare.md — Cloudflare deployment ## Commands diff --git a/README.md b/README.md index d82580027..bf7098fa9 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`). It accepts the flags of the original `@sentry/mcp-server` local server: `--access-token` and `SENTRY_ACCESS_TOKEN` still pass a static token, and every other flag is unchanged. On sentry.io, `npx -y @sentry/mcp-server --access-token=TOKEN` is a lighter option: a small proxy to the hosted MCP that does not download the CLI. 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..809eb04d9 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/ # stdio proxy to the hosted MCP (@sentry/mcp-server) ├── mcp-cloudflare/ # Cloudflare Workers deployment ├── mcp-server-evals/ # Evaluation test suite ├── mcp-server-mocks/ # Shared mock data @@ -65,11 +65,14 @@ 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). +`@sentry/mcp-server` is a small stdio proxy for clients that only need Sentry's +tools over stdio, without downloading the CLI. Its `sentry-mcp` bin forwards +stdio to the hosted MCP's Streamable HTTP endpoint with the user's token as +`Authorization: Sentry-Bearer `. It bundles the MCP SDK and nothing +else, and serves sentry.io only; for self-hosted Sentry it exits with an error that +points to `sentry mcp`. See [stdio release](../releases/stdio.md). ### packages/mcp-cloudflare @@ -135,7 +138,9 @@ Two checks enforce this in `pnpm run lint`: `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. + such as mcp-core reaching `sentry` or toolkit-core reaching mcp-core. It + also keeps `@sentry/mcp-server` (the published stdio proxy) free of every + other workspace package, so its bundle holds only the MCP SDK. ## Key Architectural Decisions diff --git a/docs/operations/stdio-auth.md b/docs/operations/stdio-auth.md index 3becdf3d9..d9b8b3850 100644 --- a/docs/operations/stdio-auth.md +++ b/docs/operations/stdio-auth.md @@ -1,18 +1,21 @@ # Stdio Authentication -How the local stdio MCP server (`sentry mcp`) authenticates with Sentry. +How the two stdio MCP entry points authenticate with Sentry: the local server +(`sentry mcp`) and the `@sentry/mcp-server` proxy to the hosted MCP. + +## `sentry mcp` `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 +### 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 + for MCP client configurations written for the original `@sentry/mcp-server` + local server. 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 @@ -34,15 +37,30 @@ 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` +## `@sentry/mcp-server` Proxy + +The `sentry-mcp` bin has no auth flow and stores nothing. It needs a static +token from `--access-token` or `SENTRY_ACCESS_TOKEN` and sends it to the +hosted MCP as `Authorization: Sentry-Bearer ` on every request. The +hosted MCP routes that scheme before OAuth and passes the token to Sentry as +is: no validation, refresh, or storage. A rejected token comes back as the +JSON-RPC error of the failed request, and the proxy logs it to stderr. + +Without a token the proxy exits 1 and tells the user to create one or run +`npx -y sentry mcp`, which can use `sentry auth login`. `sentry-mcp auth ...` +exits 1 with the same pointer. + +The hosted MCP serves sentry.io only (regional and organization subdomains go +through sentry.io). A `--host`, `--url`, `SENTRY_HOST`, or `SENTRY_URL` for +any other host, or `--insecure-http`, exits 1 with an error that points to +`npx -y sentry mcp`. -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`. +### Migration From the Old Local Server -The final `@sentry/mcp-server` release is a shim that maps -`sentry-mcp auth login|logout|status` to `sentry auth login|logout|status`. +The old package's device code flow, bundled client ID, and token cache +(`~/.sentry/mcp.json`, `SENTRY_MCP_AUTH_CACHE`) are gone. Neither entry point +reads the old cache. Users who relied on it run `sentry auth login` once and +switch to `sentry mcp`, or pass `--access-token` / `SENTRY_ACCESS_TOKEN`. ## Key Files @@ -53,3 +71,5 @@ The final `@sentry/mcp-server` release is a shim that maps | `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 | +| `packages/mcp-server/lib/config.js` | Proxy flags, token, and host checks | +| `packages/mcp-server/lib/proxy.js` | Proxy stdio to Streamable HTTP forwarding | diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index 87747db3b..1da9cf0d6 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -1,13 +1,16 @@ # stdio Release -How the local stdio MCP server ships. +How the stdio MCP entry points ship. ## 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`); there is no separate MCP package to version or -publish. +There are two: + +- **`sentry mcp`**, the full local server in the `sentry` npm package + (`packages/cli`). It ships with every CLI release (the `packages/cli` Craft + workspace in `.craft.yml`) and owns the MCP registry entry. +- **`@sentry/mcp-server`** (`packages/mcp-server`), a small stdio proxy to the + hosted MCP for sentry.io users who do not want to download the CLI. `sentry mcp` bundles `@sentry/mcp-core` (tools, server, and the stdio transport) and uses the CLI's own authentication: @@ -16,26 +19,42 @@ transport) and uses the CLI's own authentication: 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 + only, kept for configurations written for the original `@sentry/mcp-server` + local server. It is never refreshed, and org auth tokens (`sntrys_`) are still scoped to the host they were issued for. -## Retired `@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. - -Remaining maintainer steps: - -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`. +## `@sentry/mcp-server` proxy + +`packages/mcp-server` is plain JavaScript in `bin/` and `lib/`. Its only +library is `@modelcontextprotocol/sdk`, which tsdown bundles into +`dist/sentry-mcp.js` on `pnpm pack` (`prepack`), so the published package has +no runtime dependencies (about 80 kB packed, versus about 95 packages for an +unbundled SDK). `pnpm run lint:deps` fails if it gains a workspace dependency, +so the CLI and the MCP core never end up in it. + +The `sentry-mcp` bin forwards every JSON-RPC message between stdio and +`https://mcp.sentry.dev/mcp` (Streamable HTTP) with +`Authorization: Sentry-Bearer `. It maps the old local server's flags +onto the hosted URL: + +| Flag or variable | Hosted MCP | +| ---------------------------------------------- | ------------------------------ | +| `--organization-slug`, `--project-slug` | `/mcp//` | +| `--skills`, `MCP_SKILLS` | `?skills=` | +| `--disable-skills`, `MCP_DISABLE_SKILLS` | `?disable-skills=` | +| `--all-skills` | Default (no parameter) | +| `--experimental` | `?experimental=1` | +| `--mcp-url`, `MCP_URL` | Hosted MCP origin (testing) | +| `--host`, `--url`, `SENTRY_HOST`, `SENTRY_URL` | Must be sentry.io; else exit 1 | + +`--insecure-http` and the embedded agent flags (`--agent-provider`, +`--openai-*`, `--anthropic-*`) exit 1 and point to `sentry mcp`. `--sentry-dsn` +and the agent environment variables (`OPENAI_API_KEY`, ...) print a warning +and are ignored. + +It releases through the `packages/mcp-server` Craft workspace (`release/mcp` +branches) together with the other `packages/mcp-*` versions. CI packs it in +`.github/workflows/merge-jobs.yml`. ## MCP Registry @@ -77,6 +96,20 @@ Users run the server through npx in their MCP client configuration: Without a `sentry auth login` session, add `"env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." }`. Pin a version with `sentry@0.48.0`. +On sentry.io, the proxy is a smaller download: + +```json +{ + "mcpServers": { + "sentry": { + "command": "npx", + "args": ["-y", "@sentry/mcp-server@latest"], + "env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." } + } + } +} +``` + ## Testing Before a Release ```bash @@ -85,6 +118,9 @@ pnpm --silent --dir packages/cli run cli mcp --help # Exercise it with the test client pnpm -w run cli --transport stdio "who am I?" + +# Proxy tests (unit plus end to end against a local Streamable HTTP server) +pnpm --filter @sentry/mcp-server test ``` ## References @@ -92,4 +128,4 @@ 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/` +- Proxy: `packages/mcp-server/` diff --git a/package.json b/package.json index b3e077338..4000671fd 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 packages/mcp-server/lib/config.test.js packages/mcp-server/lib/proxy.test.js", "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/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 3031fb2ad..3e2f0558f 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`, kept for MCP client configurations written for the original `@sentry/mcp-server` local server (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 -- diff --git a/packages/cli/src/lib/mcp/args.ts b/packages/cli/src/lib/mcp/args.ts index d3dac31fb..f240d0f2b 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`. * - * 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. + * Accepts the same flags and environment variables as the original + * `@sentry/mcp-server` local server, so existing MCP client configurations + * keep working when they switch to `sentry mcp`. Flags win over environment values. */ import { parseArgs } from "node:util"; diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 693aab4c5..432c15e53 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -263,7 +263,7 @@ function initMcpTelemetry( zodErrorsIntegration(), vercelAIIntegration({ recordInputs: true, recordOutputs: true }), ], - // The retired package fixed this at build time; keep the same value. + // The standalone local server fixed this at build time; keep the same value. environment: "stdio", }); } diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 534ae9e8a..0279ecabc 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -1,21 +1,53 @@ -# @sentry/mcp-server (deprecated) +# @sentry/mcp-server -The Sentry stdio MCP server now ships in the Sentry CLI. Configure your MCP -client to run: +A stdio MCP server for sentry.io. It is a small proxy that forwards your MCP +client's stdio messages to the hosted Sentry MCP at `https://mcp.sentry.dev`, +so you get Sentry's tools without downloading the Sentry CLI. ```json { - "command": "npx", - "args": ["-y", "sentry", "mcp"] + "mcpServers": { + "sentry": { + "command": "npx", + "args": ["-y", "@sentry/mcp-server@latest"], + "env": { "SENTRY_ACCESS_TOKEN": "sntryu_..." } + } + } } ``` -`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. +Create a [User Auth Token](https://sentry.io/settings/account/api/auth-tokens/) +with `org:read`, `project:read`, `project:write`, `team:read`, `team:write`, and +`event:write`, and pass it as `SENTRY_ACCESS_TOKEN` or `--access-token`. The +proxy sends it to the hosted MCP as `Authorization: Sentry-Bearer ` and +does not store it. + +## Options + +| Flag or variable | Effect | +| ----------------------------------------------- | ------------------------------------- | +| `--organization-slug ` | Limit every call to one organization | +| `--project-slug ` | Also limit to one project | +| `--skills `, `MCP_SKILLS` | Grant only these skills | +| `--disable-skills `, `MCP_DISABLE_SKILLS` | Remove skills from the default set | +| `--all-skills` | Grant all active skills (the default) | +| `--experimental` | Enable experimental tools | + +## Self-hosted Sentry and local use + +The hosted MCP only talks to sentry.io. For self-hosted Sentry, your own LLM +provider, or your `sentry auth login` session, use `sentry mcp` from the +[Sentry CLI](https://cli.sentry.dev), which runs the tools locally and accepts +the same flags: -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`. +```json +{ + "command": "npx", + "args": ["-y", "sentry@latest", "mcp", "--host=sentry.example.com"] +} +``` -To delete after the final `@sentry/mcp-server` publish. +The proxy exits with an error pointing there when `--host`, `--url`, +`SENTRY_HOST`, or `SENTRY_URL` names a host other than sentry.io (or one of its +regional subdomains), or when `--insecure-http`, `--agent-provider`, +`--openai-*`, or `--anthropic-*` is set. diff --git a/packages/mcp-server/bin/sentry-mcp.js b/packages/mcp-server/bin/sentry-mcp.js index d6d061c65..64203bb40 100644 --- a/packages/mcp-server/bin/sentry-mcp.js +++ b/packages/mcp-server/bin/sentry-mcp.js @@ -1,4 +1,48 @@ #!/usr/bin/env node -import { run } from "../lib/shim.js"; +import { createRequire } from "node:module"; +import process from "node:process"; +import { UsageError, buildUsage, resolveConfig } from "../lib/config.js"; +import { startProxy } from "../lib/proxy.js"; -run(process.argv.slice(2)); +const { version } = createRequire(import.meta.url)("../package.json"); + +/** stdout carries the MCP protocol only; every diagnostic goes to stderr. */ +const log = (/** @type {string} */ message) => { + process.stderr.write(`${message}\n`); +}; + +let config; +try { + config = resolveConfig(process.argv.slice(2), process.env); +} catch (error) { + if (!(error instanceof UsageError)) throw error; + log(`Error: ${error.message}`); + process.exit(1); +} + +if (config.kind !== "run") { + // No protocol session runs for --help/--version, so stdout is free. + process.stdout.write( + `${config.kind === "help" ? buildUsage(version) : version}\n`, + ); + process.exit(0); +} + +for (const warning of config.warnings) { + log(`Warning: ${warning}`); +} + +const proxy = await startProxy({ + endpoint: config.endpoint, + accessToken: config.accessToken, + userAgent: `sentry-mcp/${version}`, + log, +}); +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => void proxy.close()); +} + +await proxy.closed; +// Flush buffered protocol output before exiting; idle HTTP sockets would +// otherwise keep the process alive. +process.stdout.write("", () => process.exit(0)); diff --git a/packages/mcp-server/lib/config.js b/packages/mcp-server/lib/config.js new file mode 100644 index 000000000..054a60fc4 --- /dev/null +++ b/packages/mcp-server/lib/config.js @@ -0,0 +1,332 @@ +/** + * Flag and environment handling for the `@sentry/mcp-server` stdio proxy. + * + * The proxy accepts the flags of the original `@sentry/mcp-server` CLI and + * maps the ones the hosted Sentry MCP supports onto its URL. Flags that only + * make sense for a local server fail with an error that points to + * `npx -y sentry mcp`, which still runs the tools locally. + */ +import { parseArgs } from "node:util"; + +const DEFAULT_MCP_URL = "https://mcp.sentry.dev"; + +const LOCAL_SERVER_HINT = + "Run `npx -y sentry mcp` instead; it runs the Sentry MCP tools locally and accepts the same flags."; + +const OPTIONS = { + "access-token": { type: "string" }, + host: { type: "string" }, + url: { type: "string" }, + "mcp-url": { type: "string" }, + "insecure-http": { type: "boolean" }, + "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" }, +}; + +/** Flags that configure the local embedded agent, which the hosted MCP owns. */ +const LOCAL_AGENT_FLAGS = [ + "agent-provider", + "openai-base-url", + "openai-model", + "anthropic-base-url", + "anthropic-model", +]; + +/** Environment variables of the local embedded agent; ignored by the proxy. */ +const LOCAL_AGENT_ENV = [ + "EMBEDDED_AGENT_PROVIDER", + "OPENAI_API_KEY", + "OPENAI_MODEL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_MODEL", + "OPENROUTER_API_KEY", + "OPENROUTER_MODEL", + "OPENROUTER_REASONING_EFFORT", +]; + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** A user-facing configuration error; the bin prints it and exits 1. */ +export class UsageError extends Error {} + +/** + * @typedef {object} ProxyConfig + * @property {"run"} kind + * @property {URL} endpoint Hosted MCP endpoint, including constraints and options + * @property {string} accessToken + * @property {string[]} warnings Diagnostics for stderr + */ + +/** + * Resolve what the bin should do from argv and the environment. + * + * @param {readonly string[]} argv + * @param {Record} env + * @returns {{ kind: "help" } | { kind: "version" } | ProxyConfig} + * @throws {UsageError} + */ +export function resolveConfig(argv, env) { + if (argv[0] === "auth") { + throw new UsageError( + "@sentry/mcp-server has no login of its own. Pass a Sentry User Auth Token with --access-token or SENTRY_ACCESS_TOKEN, or run `npx -y sentry auth login` and configure your MCP client to run `npx -y sentry mcp`.", + ); + } + + const { values, tokens } = parseArgs({ + args: [...argv], + options: OPTIONS, + allowPositionals: true, + strict: false, + tokens: true, + }); + + const unknown = tokens.flatMap((token) => { + if (token.kind === "positional") return [token.value]; + if (token.kind === "option" && !Object.hasOwn(OPTIONS, token.name)) { + return [token.rawName]; + } + return []; + }); + if (unknown.length > 0) { + throw new UsageError( + `Invalid argument(s): ${unknown.join(", ")}\nRun \`sentry-mcp --help\` for usage.`, + ); + } + + if (values.help === true) return { kind: "help" }; + if (values.version === true) return { kind: "version" }; + + /** @param {string} name */ + const flag = (name) => { + const value = values[name]; + if (value === true && OPTIONS[name].type === "string") { + throw new UsageError(`--${name} requires a value.`); + } + return typeof value === "string" ? value : undefined; + }; + + for (const name of LOCAL_AGENT_FLAGS) { + if (flag(name) !== undefined) { + throw new UsageError( + `--${name} is not supported: @sentry/mcp-server forwards to the hosted Sentry MCP, which uses its own embedded agent. ${LOCAL_SERVER_HINT}`, + ); + } + } + + assertHostedSentryHost({ + url: flag("url") ?? env.SENTRY_URL, + host: flag("host") ?? env.SENTRY_HOST, + insecureHttp: values["insecure-http"] === true, + }); + + const accessToken = (flag("access-token") ?? env.SENTRY_ACCESS_TOKEN)?.trim(); + if (!accessToken) { + throw new UsageError( + [ + "No Sentry access token. @sentry/mcp-server forwards to the hosted Sentry MCP and needs a User Auth Token:", + " pass --access-token= or set SENTRY_ACCESS_TOKEN.", + "Create one at https://sentry.io/settings/account/api/auth-tokens/ (scopes: org:read, project:read, project:write, team:read, team:write, event:write).", + "To use your `sentry auth login` session instead, configure your MCP client to run `npx -y sentry mcp`.", + ].join("\n"), + ); + } + + const warnings = []; + if (flag("sentry-dsn") !== undefined) { + warnings.push( + "--sentry-dsn is ignored: the hosted Sentry MCP reports its own telemetry.", + ); + } + const agentEnv = LOCAL_AGENT_ENV.filter((name) => env[name]); + if (agentEnv.length > 0) { + warnings.push( + `Ignoring ${agentEnv.join(", ")}: the hosted Sentry MCP uses its own embedded agent. ${LOCAL_SERVER_HINT}`, + ); + } + + return { + kind: "run", + endpoint: buildEndpoint({ + mcpUrl: flag("mcp-url") ?? env.MCP_URL, + organizationSlug: flag("organization-slug"), + projectSlug: flag("project-slug"), + skills: selectSkills( + flag("skills"), + values["all-skills"] === true, + env.MCP_SKILLS, + ), + disableSkills: flag("disable-skills") ?? env.MCP_DISABLE_SKILLS, + experimental: values.experimental === true, + }), + accessToken, + warnings, + }; +} + +/** + * Whether the hosted MCP can serve `hostname`. It only talks to sentry.io, + * which also routes regional (`us.`, `de.`) and organization subdomains. + * + * @param {string} hostname + */ +export function isHostedSentryHost(hostname) { + const host = hostname.toLowerCase(); + return host === "sentry.io" || host.endsWith(".sentry.io"); +} + +/** + * Reject Sentry hosts the hosted MCP cannot reach, such as self-hosted Sentry. + * `--url`/`SENTRY_URL` wins over `--host`/`SENTRY_HOST`, as in the old CLI. + * + * @param {{ url?: string, host?: string, insecureHttp: boolean }} input + */ +function assertHostedSentryHost({ url, host, insecureHttp }) { + let hostname = host; + if (url) { + let parsed; + try { + parsed = new URL(url); + } catch { + throw new UsageError(`Invalid Sentry URL: ${url}`); + } + hostname = parsed.protocol === "https:" ? parsed.host : url; + } + const reason = insecureHttp + ? "--insecure-http only applies to self-hosted Sentry" + : hostname && !isHostedSentryHost(hostname) + ? `cannot reach ${hostname}` + : undefined; + if (reason) { + throw new UsageError( + `@sentry/mcp-server only connects to sentry.io through the hosted Sentry MCP; ${reason}. For self-hosted Sentry, run \`npx -y sentry mcp\` with the same flags; it runs the tools locally against any host.`, + ); + } +} + +/** + * Apply the old CLI's skill precedence: `--skills` beats `--all-skills`, + * which beats `MCP_SKILLS`. `undefined` means the hosted default (all + * active skills, the same set as `--all-skills`). + * + * @param {string | undefined} skills + * @param {boolean} allSkills + * @param {string | undefined} envSkills + */ +function selectSkills(skills, allSkills, envSkills) { + if (skills !== undefined && allSkills) { + throw new UsageError("--all-skills cannot be combined with --skills."); + } + return allSkills ? undefined : (skills ?? envSkills); +} + +/** + * Build the hosted MCP endpoint. Constraints are path segments + * (`/mcp//`); skills and experimental mode are query + * parameters the hosted MCP validates. + * + * @param {{ + * mcpUrl?: string, + * organizationSlug?: string, + * projectSlug?: string, + * skills?: string, + * disableSkills?: string, + * experimental: boolean, + * }} input + * @returns {URL} + */ +function buildEndpoint({ + mcpUrl = DEFAULT_MCP_URL, + organizationSlug, + projectSlug, + skills, + disableSkills, + experimental, +}) { + let endpoint; + try { + endpoint = new URL(mcpUrl); + } catch { + throw new UsageError(`Invalid MCP URL: ${mcpUrl}`); + } + const isLoopback = + endpoint.protocol === "http:" && LOOPBACK_HOSTS.has(endpoint.hostname); + if (endpoint.protocol !== "https:" && !isLoopback) { + throw new UsageError( + `The MCP URL must use https:// (http:// is allowed for localhost only): ${mcpUrl}`, + ); + } + + if (projectSlug && !organizationSlug) { + throw new UsageError("--project-slug requires --organization-slug."); + } + const base = endpoint.pathname.replace(/\/+$/, ""); + const segments = [base.endsWith("/mcp") ? base : `${base}/mcp`]; + for (const slug of [organizationSlug, projectSlug]) { + if (slug) segments.push(encodeURIComponent(slug)); + } + endpoint.pathname = segments.join("/"); + + for (const [name, list] of [ + ["skills", skills], + ["disable-skills", disableSkills], + ]) { + if (list === undefined) continue; + const entries = list + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + if (entries.length === 0) { + throw new UsageError(`--${name} must list at least one skill.`); + } + endpoint.searchParams.set(name, entries.join(",")); + } + if (experimental) { + endpoint.searchParams.set("experimental", "1"); + } + return endpoint; +} + +/** + * @param {string} version + * @returns {string} + */ +export function buildUsage(version) { + return `@sentry/mcp-server ${version}: stdio proxy to the hosted Sentry MCP (sentry.io only) + +Usage: sentry-mcp --access-token= [options] + +Authentication: + --access-token Sentry User Auth Token (or SENTRY_ACCESS_TOKEN) + +Session constraints: + --organization-slug Force all calls to an organization + --project-slug Optional project constraint (needs --organization-slug) + +Skill controls: + --skills Grant only these skills (or MCP_SKILLS) + --all-skills Grant all active skills (the default) + --disable-skills Remove skills (or MCP_DISABLE_SKILLS) + --experimental Enable experimental tools + +Other: + --mcp-url Hosted MCP origin (or MCP_URL; default ${DEFAULT_MCP_URL}) + -h, --help Show this help + -v, --version Show the version + +Self-hosted Sentry, your own LLM provider, and \`sentry auth login\` need the +local server instead: + + npx -y sentry mcp`; +} diff --git a/packages/mcp-server/lib/config.test.js b/packages/mcp-server/lib/config.test.js new file mode 100644 index 000000000..dbecebf33 --- /dev/null +++ b/packages/mcp-server/lib/config.test.js @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { UsageError, isHostedSentryHost, resolveConfig } from "./config.js"; + +const TOKEN = ["--access-token=TOKEN"]; + +/** + * @param {string[]} argv + * @param {Record} [env] + */ +function endpointFor(argv, env = {}) { + const config = resolveConfig([...TOKEN, ...argv], env); + assert.equal(config.kind, "run"); + return config.endpoint.href; +} + +/** + * @param {string[]} argv + * @param {RegExp} message + * @param {Record} [env] + */ +function assertUsageError(argv, message, env = {}) { + assert.throws( + () => resolveConfig(argv, env), + (error) => error instanceof UsageError && message.test(error.message), + ); +} + +describe("resolveConfig", () => { + it("targets the hosted MCP with the token from the flag or the environment", () => { + const fromFlag = resolveConfig(TOKEN, { SENTRY_ACCESS_TOKEN: "ENV" }); + assert.equal(fromFlag.kind, "run"); + assert.equal(fromFlag.accessToken, "TOKEN"); + assert.equal(fromFlag.endpoint.href, "https://mcp.sentry.dev/mcp"); + assert.deepEqual(fromFlag.warnings, []); + + const fromEnv = resolveConfig([], { SENTRY_ACCESS_TOKEN: "ENV" }); + assert.equal(fromEnv.kind, "run"); + assert.equal(fromEnv.accessToken, "ENV"); + }); + + it("maps constraints to path segments", () => { + assert.equal( + endpointFor(["--organization-slug=acme"]), + "https://mcp.sentry.dev/mcp/acme", + ); + assert.equal( + endpointFor(["--organization-slug", "acme", "--project-slug", "web"]), + "https://mcp.sentry.dev/mcp/acme/web", + ); + assertUsageError( + [...TOKEN, "--project-slug=web"], + /--project-slug requires --organization-slug/, + ); + }); + + it("maps skills and experimental mode to query parameters", () => { + assert.equal( + endpointFor(["--skills=inspect, triage", "--disable-skills=seer"]), + "https://mcp.sentry.dev/mcp?skills=inspect%2Ctriage&disable-skills=seer", + ); + assert.equal( + endpointFor(["--experimental"]), + "https://mcp.sentry.dev/mcp?experimental=1", + ); + assert.equal( + endpointFor([], { MCP_SKILLS: "inspect", MCP_DISABLE_SKILLS: "docs" }), + "https://mcp.sentry.dev/mcp?skills=inspect&disable-skills=docs", + ); + }); + + it("keeps the old skill precedence", () => { + assert.equal( + endpointFor(["--skills=triage"], { MCP_SKILLS: "inspect" }), + "https://mcp.sentry.dev/mcp?skills=triage", + ); + // --all-skills is the hosted default, and it overrides MCP_SKILLS. + assert.equal( + endpointFor(["--all-skills"], { MCP_SKILLS: "inspect" }), + "https://mcp.sentry.dev/mcp", + ); + assertUsageError( + [...TOKEN, "--all-skills", "--skills=inspect"], + /cannot be combined/, + ); + assertUsageError([...TOKEN, "--skills=,"], /at least one skill/); + }); + + it("accepts an MCP origin override for local testing", () => { + assert.equal( + endpointFor(["--mcp-url=http://localhost:8788", "--organization-slug=a"]), + "http://localhost:8788/mcp/a", + ); + assert.equal( + endpointFor([], { MCP_URL: "https://mcp.example.dev/mcp" }), + "https://mcp.example.dev/mcp", + ); + assertUsageError( + [...TOKEN, "--mcp-url=http://mcp.example.dev"], + /must use https/, + ); + assertUsageError([...TOKEN, "--mcp-url=nope"], /Invalid MCP URL/); + }); + + it("accepts sentry.io hosts, including regions", () => { + for (const host of ["sentry.io", "us.sentry.io", "de.sentry.io"]) { + assert.equal( + endpointFor([`--host=${host}`]), + "https://mcp.sentry.dev/mcp", + ); + } + assert.equal( + endpointFor([], { SENTRY_URL: "https://de.sentry.io/" }), + "https://mcp.sentry.dev/mcp", + ); + }); + + it("rejects self-hosted Sentry and points to `sentry mcp`", () => { + const selfHosted = /cannot reach sentry\.example\.com.*npx -y sentry mcp/; + assertUsageError([...TOKEN, "--host=sentry.example.com"], selfHosted); + assertUsageError( + [...TOKEN, "--url=https://sentry.example.com"], + selfHosted, + ); + assertUsageError(TOKEN, selfHosted, { SENTRY_HOST: "sentry.example.com" }); + assertUsageError(TOKEN, selfHosted, { + SENTRY_URL: "https://sentry.example.com", + }); + assertUsageError( + [...TOKEN, "--url=http://sentry.io"], + /cannot reach http:\/\/sentry\.io/, + ); + assertUsageError( + [...TOKEN, "--insecure-http"], + /--insecure-http only applies to self-hosted.*npx -y sentry mcp/, + ); + }); + + it("explains how to get a token when none is set", () => { + assertUsageError( + [], + /No Sentry access token[\s\S]*SENTRY_ACCESS_TOKEN[\s\S]*npx -y sentry mcp/, + ); + assertUsageError(["--access-token= "], /No Sentry access token/); + }); + + it("points the old auth commands to the CLI", () => { + for (const argv of [["auth"], ["auth", "login"], ["auth", "status"]]) { + assertUsageError(argv, /npx -y sentry auth login/); + } + }); + + it("rejects local embedded agent flags", () => { + for (const flag of [ + "--agent-provider=anthropic", + "--openai-base-url=https://example.openai.azure.com/openai/v1/", + "--openai-model=gpt-5", + "--anthropic-base-url=https://example.com", + "--anthropic-model=claude", + ]) { + assertUsageError([...TOKEN, flag], /own embedded agent.*sentry mcp/); + } + }); + + it("warns about settings the hosted MCP ignores", () => { + const config = resolveConfig( + [...TOKEN, "--sentry-dsn=https://k@o.ingest"], + { + OPENAI_API_KEY: "sk", + EMBEDDED_AGENT_PROVIDER: "openai", + }, + ); + assert.equal(config.kind, "run"); + assert.equal(config.warnings.length, 2); + assert.match(config.warnings[0], /--sentry-dsn is ignored/); + assert.match( + config.warnings[1], + /Ignoring EMBEDDED_AGENT_PROVIDER, OPENAI_API_KEY/, + ); + }); + + it("rejects unknown arguments and missing values", () => { + assertUsageError([...TOKEN, "--nope"], /Invalid argument\(s\): --nope/); + assertUsageError([...TOKEN, "extra"], /Invalid argument\(s\): extra/); + assertUsageError([...TOKEN, "--host"], /--host requires a value/); + }); + + it("handles --help and --version before validation", () => { + assert.deepEqual(resolveConfig(["--help"], {}), { kind: "help" }); + assert.deepEqual(resolveConfig(["-v", "--host=sentry.example.com"], {}), { + kind: "version", + }); + }); +}); + +describe("isHostedSentryHost", () => { + it("matches sentry.io and its subdomains only", () => { + assert.equal(isHostedSentryHost("sentry.io"), true); + assert.equal(isHostedSentryHost("US.sentry.io"), true); + assert.equal(isHostedSentryHost("acme.sentry.io"), true); + assert.equal(isHostedSentryHost("notsentry.io"), false); + assert.equal(isHostedSentryHost("sentry.io.example.com"), false); + assert.equal(isHostedSentryHost("sentry.io:8080"), false); + }); +}); diff --git a/packages/mcp-server/lib/proxy.js b/packages/mcp-server/lib/proxy.js new file mode 100644 index 000000000..efbc25c24 --- /dev/null +++ b/packages/mcp-server/lib/proxy.js @@ -0,0 +1,152 @@ +/** + * Bridges an MCP client on stdio to the hosted Sentry MCP over Streamable HTTP. + * + * Messages are forwarded unchanged in both directions. The proxy only tracks + * what it needs to behave like a stdio server: the negotiated protocol + * version (sent back as a header on later HTTP requests) and which client + * requests still await a response, so closing stdin lets in-flight calls + * finish before the process exits. + */ +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +const INTERNAL_ERROR = -32603; +const SESSION_TERMINATE_TIMEOUT_MS = 2000; + +/** + * @typedef {import("@modelcontextprotocol/sdk/types.js").JSONRPCMessage} JSONRPCMessage + */ + +/** @param {JSONRPCMessage} message */ +function isRequest(message) { + return "method" in message && "id" in message; +} + +/** @param {JSONRPCMessage} message */ +function isResponse(message) { + return "id" in message && ("result" in message || "error" in message); +} + +/** @param {unknown} error */ +function describe(error) { + const message = error instanceof Error ? error.message : String(error); + const status = + error && typeof error === "object" && "code" in error + ? error.code + : undefined; + return status === 401 + ? `${message} (check --access-token / SENTRY_ACCESS_TOKEN)` + : message; +} + +/** + * Start forwarding between stdio and the hosted MCP. + * + * @param {{ + * endpoint: URL, + * accessToken: string, + * userAgent: string, + * log: (message: string) => void, + * }} options + * @returns {Promise<{ closed: Promise, close: () => Promise }>} + */ +export async function startProxy({ endpoint, accessToken, userAgent, log }) { + const local = new StdioServerTransport(); + const remote = new StreamableHTTPClientTransport(endpoint, { + requestInit: { + headers: { + Authorization: `Sentry-Bearer ${accessToken}`, + "User-Agent": userAgent, + }, + }, + }); + + /** Client request ids that have not been answered yet. */ + const pending = new Set(); + const initializeIds = new Set(); + let stdinEnded = false; + /** @type {Promise | undefined} */ + let closing; + + const close = () => { + closing ??= (async () => { + // Best effort: let a stateful server drop the session. Errors are + // reported through `onerror`, which is silent while closing. + await Promise.race([ + remote.terminateSession().catch(() => {}), + delay(SESSION_TERMINATE_TIMEOUT_MS, undefined, { ref: false }), + ]); + await remote.close(); + await local.close(); + })(); + return closing; + }; + + const closeWhenDrained = () => { + if (stdinEnded && pending.size === 0) { + void close(); + } + }; + + /** @param {JSONRPCMessage} message */ + const toClient = (message) => + local.send(message).then(closeWhenDrained, (error) => { + log(`Failed to write to stdout: ${describe(error)}`); + }); + + local.onmessage = (message) => { + if (isRequest(message)) { + pending.add(message.id); + if (message.method === "initialize") { + initializeIds.add(message.id); + } + } else if (message.method === "notifications/cancelled") { + pending.delete(message.params?.requestId); + } + remote.send(message).catch((error) => { + // `onerror` already logged it; a request also needs an answer so the + // client does not wait forever. + if (isRequest(message) && pending.delete(message.id)) { + initializeIds.delete(message.id); + void toClient({ + jsonrpc: "2.0", + id: message.id, + error: { code: INTERNAL_ERROR, message: describe(error) }, + }); + } + }); + }; + local.onerror = (error) => + log(`Invalid message on stdin: ${describe(error)}`); + + remote.onmessage = (message) => { + if (isResponse(message)) { + pending.delete(message.id); + if (initializeIds.delete(message.id) && "result" in message) { + // Later requests must carry the negotiated `MCP-Protocol-Version`. + remote.setProtocolVersion(String(message.result.protocolVersion)); + } + } + void toClient(message); + }; + remote.onerror = (error) => { + if (!closing) { + log(`Sentry MCP: ${describe(error)}`); + } + }; + + const closed = new Promise((resolve) => { + local.onclose = () => resolve(undefined); + }); + + process.stdin.once("end", () => { + stdinEnded = true; + closeWhenDrained(); + }); + + await local.start(); + await remote.start(); + return { closed, close }; +} diff --git a/packages/mcp-server/lib/proxy.test.js b/packages/mcp-server/lib/proxy.test.js new file mode 100644 index 000000000..eed2f9a62 --- /dev/null +++ b/packages/mcp-server/lib/proxy.test.js @@ -0,0 +1,295 @@ +/** + * End-to-end tests: run the `sentry-mcp` bin against a local Streamable HTTP + * MCP server that stands in for the hosted Sentry MCP. + */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { fileURLToPath } from "node:url"; +import { after, before, describe, it } from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + CallToolRequestSchema, + LATEST_PROTOCOL_VERSION, + ListToolsRequestSchema, + LoggingMessageNotificationSchema, + RootsListChangedNotificationSchema, + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +const BIN = fileURLToPath(new URL("../bin/sentry-mcp.js", import.meta.url)); +const TOKEN = "sntryu_test-token"; + +/** A stateful MCP server on a random port that records request headers. */ +async function startMockHostedMcp() { + /** @type {{ method: string, url: string, authorization?: string, protocolVersion?: string }[]} */ + const requests = []; + const clientNotifications = []; + /** @type {Map} */ + const sessions = new Map(); + + function createSession() { + const server = new Server( + { name: "mock-hosted-sentry-mcp", version: "1.0.0" }, + { capabilities: { tools: { listChanged: true }, logging: {} } }, + ); + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [ + { + name: "whoami", + description: "Echo the caller", + inputSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + ], + })); + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const progressToken = request.params._meta?.progressToken; + if (progressToken !== undefined) { + await extra.sendNotification({ + method: "notifications/progress", + params: { progressToken, progress: 1, total: 2 }, + }); + // The SDK client drops progress that it handles after the response. + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await extra.sendNotification({ + method: "notifications/message", + params: { level: "info", data: "looking you up" }, + }); + // Out-of-band notification: travels over the standalone GET stream. + setTimeout(() => void server.sendToolListChanged(), 50); + return { + content: [ + { + type: "text", + text: `hello ${String(request.params.arguments?.name)}`, + }, + ], + }; + }); + server.setNotificationHandler(RootsListChangedNotificationSchema, (n) => { + clientNotifications.push(n.method); + }); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: (id) => sessions.set(id, transport), + onsessionclosed: (id) => sessions.delete(id), + }); + void server.connect(transport); + return transport; + } + + const http = createServer((req, res) => { + requests.push({ + method: req.method ?? "", + url: req.url ?? "", + authorization: req.headers.authorization, + protocolVersion: req.headers["mcp-protocol-version"]?.toString(), + }); + if (req.headers.authorization !== `Sentry-Bearer ${TOKEN}`) { + res.writeHead(401).end("Missing or invalid Sentry-Bearer token"); + return; + } + const sessionId = req.headers["mcp-session-id"]?.toString(); + const transport = sessionId ? sessions.get(sessionId) : createSession(); + if (!transport) { + res.writeHead(404).end("Unknown session"); + return; + } + void transport.handleRequest(req, res); + }); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + const address = http.address(); + assert.ok(address && typeof address === "object"); + return { + origin: `http://127.0.0.1:${address.port}`, + requests, + clientNotifications, + async close() { + await Promise.all([...sessions.values()].map((t) => t.close())); + http.closeAllConnections(); + http.close(); + }, + }; +} + +/** + * @param {string[]} args + * @returns {Promise<{ code: number | null, stdout: string, stderr: string }>} + */ +async function runBin(args, { input = "", env = {} } = {}) { + const child = spawn(process.execPath, [BIN, ...args], { + env: { PATH: process.env.PATH, ...env }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.stdin.end(input); + const [code] = await once(child, "exit"); + return { code, stdout, stderr }; +} + +const initialize = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "raw", version: "0" }, + }, +}; + +describe("sentry-mcp proxy", () => { + /** @type {Awaited>} */ + let hosted; + before(async () => { + hosted = await startMockHostedMcp(); + }); + after(async () => { + await hosted.close(); + }); + + it("forwards requests, responses, and notifications both ways", async () => { + hosted.requests.length = 0; + const stderr = []; + const transport = new StdioClientTransport({ + command: process.execPath, + args: [ + BIN, + `--mcp-url=${hosted.origin}`, + `--access-token=${TOKEN}`, + "--organization-slug=acme", + "--skills=inspect", + ], + stderr: "pipe", + }); + transport.stderr?.on("data", (chunk) => stderr.push(String(chunk))); + + const client = new Client( + { name: "test-client", version: "1.0.0" }, + { capabilities: { roots: { listChanged: true } } }, + ); + const notifications = []; + const listChanged = new Promise((resolve) => { + client.setNotificationHandler(ToolListChangedNotificationSchema, (n) => { + notifications.push(n.method); + resolve(undefined); + }); + }); + client.setNotificationHandler(LoggingMessageNotificationSchema, (n) => { + notifications.push(`${n.method}:${String(n.params.data)}`); + }); + + await client.connect(transport); + assert.equal(client.getServerVersion()?.name, "mock-hosted-sentry-mcp"); + + const { tools } = await client.listTools(); + assert.deepEqual( + tools.map((tool) => tool.name), + ["whoami"], + ); + + const progress = []; + const result = await client.callTool( + { name: "whoami", arguments: { name: "sentry" } }, + undefined, + { onprogress: (p) => progress.push(p.progress) }, + ); + assert.deepEqual(result.content, [{ type: "text", text: "hello sentry" }]); + assert.deepEqual(progress, [1]); + + await client.sendRootsListChanged(); + await listChanged; + assert.deepEqual(notifications, [ + "notifications/message:looking you up", + "notifications/tools/list_changed", + ]); + assert.deepEqual(hosted.clientNotifications, [ + "notifications/roots/list_changed", + ]); + + await client.close(); + + assert.ok(hosted.requests.length > 0); + for (const request of hosted.requests) { + assert.equal(request.authorization, `Sentry-Bearer ${TOKEN}`); + assert.equal(request.url, "/mcp/acme?skills=inspect"); + } + // Every request after initialize carries the negotiated version. + assert.equal(hosted.requests[0].protocolVersion, undefined); + assert.ok( + hosted.requests + .slice(1) + .every((r) => r.protocolVersion === LATEST_PROTOCOL_VERSION), + ); + assert.equal(stderr.join(""), ""); + }); + + it("answers requests the hosted MCP rejects with a JSON-RPC error", async () => { + const { code, stdout, stderr } = await runBin( + [`--mcp-url=${hosted.origin}`, "--access-token=wrong"], + { input: `${JSON.stringify(initialize)}\n` }, + ); + assert.equal(code, 0); + const response = JSON.parse(stdout); + assert.equal(response.id, 1); + assert.match(response.error.message, /HTTP|401|invalid Sentry-Bearer/i); + assert.match(response.error.message, /SENTRY_ACCESS_TOKEN/); + assert.match(stderr, /Sentry MCP: .*invalid Sentry-Bearer token/); + }); + + it("finishes in-flight requests and exits cleanly when stdin closes", async () => { + const { code, stdout } = await runBin( + [`--mcp-url=${hosted.origin}`, `--access-token=${TOKEN}`], + { input: `${JSON.stringify(initialize)}\n` }, + ); + assert.equal(code, 0); + const lines = stdout.trim().split("\n"); + assert.equal(lines.length, 1); + const response = JSON.parse(lines[0]); + assert.equal(response.id, 1); + assert.equal(response.result.serverInfo.name, "mock-hosted-sentry-mcp"); + }); + + it("exits cleanly on SIGTERM", async () => { + const child = spawn( + process.execPath, + [BIN, `--mcp-url=${hosted.origin}`, `--access-token=${TOKEN}`], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + child.stdin.write(`${JSON.stringify(initialize)}\n`); + await once(child.stdout, "data"); + child.kill("SIGTERM"); + const [code] = await once(child, "exit"); + assert.equal(code, 0); + }); + + it("exits 1 without touching stdout when no token is set", async () => { + const { code, stdout, stderr } = await runBin([]); + assert.equal(code, 1); + assert.equal(stdout, ""); + assert.match(stderr, /No Sentry access token/); + assert.match(stderr, /npx -y sentry mcp/); + }); + + it("exits 1 for self-hosted Sentry", async () => { + const { code, stdout, stderr } = await runBin([], { + env: { SENTRY_ACCESS_TOKEN: TOKEN, SENTRY_HOST: "sentry.example.com" }, + }); + assert.equal(code, 1); + assert.equal(stdout, ""); + assert.match(stderr, /cannot reach sentry\.example\.com/); + assert.match(stderr, /npx -y sentry mcp/); + }); +}); 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..1775b9fb9 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,7 +1,7 @@ { "name": "@sentry/mcp-server", "version": "0.42.0", - "description": "Deprecated: use `sentry mcp` from the `sentry` package instead", + "description": "Sentry MCP over stdio: a small proxy to the hosted Sentry MCP (sentry.io)", "sentryCi": { "enabled": false }, @@ -29,16 +29,18 @@ "url": "git+https://github.com/getsentry/toolkit.git" }, "bin": { - "sentry-mcp": "./bin/sentry-mcp.js" + "sentry-mcp": "./dist/sentry-mcp.js" }, "files": [ - "bin/sentry-mcp.js", - "lib/shim.js" + "dist/sentry-mcp.js" ], "scripts": { - "test": "node --test lib/shim.test.js" + "build": "tsdown", + "prepack": "tsdown", + "test": "node --test lib/*.test.js" }, - "dependencies": { - "sentry": "workspace:>=0.48.0-dev.0" + "devDependencies": { + "@modelcontextprotocol/sdk": "catalog:", + "tsdown": "catalog:" } } diff --git a/packages/mcp-server/tsdown.config.ts b/packages/mcp-server/tsdown.config.ts new file mode 100644 index 000000000..4759ae36d --- /dev/null +++ b/packages/mcp-server/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +// Bundle the MCP SDK into one self-contained file, so installing the proxy +// pulls no runtime dependencies (the SDK alone brings ~95 packages). +export default defineConfig({ + entry: { "sentry-mcp": "bin/sentry-mcp.js" }, + format: ["esm"], + platform: "node", + target: "node22.13", + outDir: "dist", + clean: true, + minify: false, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f4ecf913..1e7fbf224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -853,10 +853,13 @@ importers: version: 2.8.3 packages/mcp-server: - dependencies: - sentry: - specifier: workspace:>=0.48.0-dev.0 - version: link:../cli + devDependencies: + '@modelcontextprotocol/sdk': + specifier: 1.32.0 + version: 1.32.0(@cfworker/json-schema@4.1.1)(zod@4.6.7) + tsdown: + specifier: 'catalog:' + version: 0.12.9(typescript@5.9.3) packages/mcp-server-evals: dependencies: diff --git a/scripts/check-workspace-deps.mjs b/scripts/check-workspace-deps.mjs index 37ce59af3..fdbe052e9 100644 --- a/scripts/check-workspace-deps.mjs +++ b/scripts/check-workspace-deps.mjs @@ -4,7 +4,9 @@ * 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. + * workspace packages. `@sentry/mcp-server` is a published stdio proxy built + * from the MCP SDK alone, so it must not pull in any of them. See + * docs/architecture/overview.md. */ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; @@ -22,6 +24,12 @@ const DEPENDENCY_FIELDS = [ export const FORBIDDEN_DEPENDENCIES = { "@sentry/toolkit-core": ["sentry", "@sentry/mcp-core"], "@sentry/mcp-core": ["sentry", "@sentry/mcp-cloudflare"], + "@sentry/mcp-server": [ + "sentry", + "@sentry/mcp-core", + "@sentry/mcp-cloudflare", + "@sentry/toolkit-core", + ], }; /** diff --git a/scripts/check-workspace-deps.test.mjs b/scripts/check-workspace-deps.test.mjs index a6ffbae5b..81f6e341e 100644 --- a/scripts/check-workspace-deps.test.mjs +++ b/scripts/check-workspace-deps.test.mjs @@ -17,7 +17,10 @@ const layered = [ dependencies: { "@sentry/mcp-core": "workspace:*" }, }, { name: "@sentry/toolkit-core" }, - { name: "@sentry/mcp-server", dependencies: { sentry: "workspace:*" } }, + { + name: "@sentry/mcp-server", + dependencies: { "@modelcontextprotocol/sdk": "catalog:" }, + }, ]; describe("check-workspace-deps", () => { @@ -58,6 +61,23 @@ describe("check-workspace-deps", () => { ]); }); + it("rejects the mcp-server proxy depending on the CLI or MCP core", () => { + const graph = buildGraph([ + ...layered.filter(({ name }) => name !== "@sentry/mcp-server"), + { name: "@sentry/mcp-server", dependencies: { sentry: "workspace:*" } }, + ]); + assert.deepEqual(findForbiddenPaths(graph), [ + ["@sentry/mcp-server", "sentry"], + ["@sentry/mcp-server", "sentry", "@sentry/mcp-core"], + [ + "@sentry/mcp-server", + "sentry", + "@sentry/mcp-core", + "@sentry/toolkit-core", + ], + ]); + }); + it("rejects toolkit-core depending on mcp-core", () => { const graph = buildGraph([ ...layered.filter(({ name }) => name !== "@sentry/toolkit-core"),