diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index a82bdd82a..75681e670 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -92,6 +92,7 @@ toolkit/ │ │ │ ├── help.ts # Help command │ │ │ ├── info.ts # Print configuration and verify authentication │ │ │ ├── init.ts # Initialize Sentry in your project (experimental) +│ │ │ ├── mcp.ts # Start a local Sentry MCP server │ │ │ ├── schema.ts # Browse the Sentry API schema │ │ │ └── wasm-split.ts# Add build ids to WebAssembly modules and split out debug data │ │ ├── lib/ # Shared utilities diff --git a/apps/cli-docs/src/fragments/commands/mcp.md b/apps/cli-docs/src/fragments/commands/mcp.md new file mode 100644 index 000000000..54482a642 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/mcp.md @@ -0,0 +1,30 @@ + +`sentry mcp` starts the local stdio Sentry MCP server using the active Sentry CLI session. Authenticate once with `sentry auth login`; no separate MCP login or token cache is required. + +## Configure an MCP client + +Point your MCP client at the installed Sentry CLI: + +```json +{ + "mcpServers": { + "sentry": { + "command": "sentry", + "args": ["mcp"] + } + } +} +``` + +For a self-hosted instance, first authenticate the CLI against that host. You can also override the target for this MCP server invocation: + +```json +{ + "mcpServers": { + "sentry": { + "command": "sentry", + "args": ["mcp", "--host=sentry.example.com"] + } + } +} +``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 10a5ca8b7..74750a5f1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,16 +58,16 @@ "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", "cli": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", - "build": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", - "build:all": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", - "bundle": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", - "typecheck": "pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit", + "build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", + "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", + "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", + "typecheck": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit", "lint": "biome check --no-errors-on-unmatched --error-on-warnings --max-diagnostics=none ./", "lint:fix": "biome check --write --no-errors-on-unmatched --max-diagnostics=none ./", "test": "pnpm run test:unit", "test:unit": "pnpm run generate:docs && pnpm run generate:sdk && vitest run test/lib test/commands test/types test/script --coverage", "test:changed": "pnpm run generate:docs && pnpm run generate:sdk && vitest run --changed", - "test:e2e": "pnpm run generate:docs && pnpm run generate:sdk && vitest run test/e2e", + "test:e2e": "pnpm tsx script/prepare-e2e-bundle.ts && vitest run test/e2e", "test:init-eval": "vitest run test/init-eval --testTimeout 600000", "generate:parser": "pnpm tsx script/generate-parser.ts", "generate:sdk": "pnpm tsx script/generate-sdk.ts", @@ -92,6 +92,7 @@ "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs" }, "devDependencies": { + "@sentry/mcp-server": "workspace:*", "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", "@clack/prompts": "0.11.0", diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 30fceeb72..4e163e2dc 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -648,6 +648,14 @@ View Sentry logs → Full flags and examples: `references/log.md` +### Mcp + +Start a local Sentry MCP server + +- `sentry mcp` — Start a local Sentry MCP server + +→ Full flags and examples: `references/mcp.md` + ### Monitor Work with Sentry cron monitors diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md new file mode 100644 index 000000000..17be67532 --- /dev/null +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md @@ -0,0 +1,18 @@ +--- +name: sentry-cli-mcp +version: 0.47.0-dev.0 +description: Start a local Sentry MCP server +requires: + bins: ["sentry"] + auth: true +--- + +# Mcp Commands + +Start a local Sentry MCP server + +### `sentry mcp` + +Start a local Sentry MCP server + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/prepare-e2e-bundle.ts b/packages/cli/script/prepare-e2e-bundle.ts new file mode 100644 index 000000000..3a79556fc --- /dev/null +++ b/packages/cli/script/prepare-e2e-bundle.ts @@ -0,0 +1,19 @@ +import { spawn } from "node:child_process"; + +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const child = spawn(pnpm, ["run", "bundle"], { + env: { + ...process.env, + SENTRY_CLIENT_ID: process.env.SENTRY_CLIENT_ID ?? "test-client-id", + }, + stdio: "inherit", +}); + +const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); +}); + +if (exitCode !== 0) { + process.exitCode = exitCode; +} diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index c1a11012b..7d03db363 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -34,6 +34,7 @@ import { listCommand as issueListCommand } from "./commands/issue/list.js"; import { localRoute } from "./commands/local/index.js"; import { logRoute } from "./commands/log/index.js"; import { listCommand as logListCommand } from "./commands/log/list.js"; +import { mcpCommand } from "./commands/mcp.js"; import { monitorRoute } from "./commands/monitor/index.js"; import { listCommand as monitorListCommand } from "./commands/monitor/list.js"; import { orgRoute } from "./commands/org/index.js"; @@ -142,6 +143,7 @@ export const routes = buildRouteMap({ explore: exploreCommand, feedback: feedbackRoute, log: logRoute, + mcp: mcpCommand, monitor: monitorRoute, snapshots: snapshotsRoute, sourcemap: sourcemapRoute, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1b8580abf..bed5d631d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,6 +13,7 @@ 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 { initTimezone } from "./lib/timezone.js"; /** @@ -229,6 +230,83 @@ type ErrorMiddleware = ( retryArgs: string[] ) => Promise; +/** + * Return MCP's arguments when it is the command after leading global flags. + * + * MCP owns stdout for JSON-RPC, so CLI-level output flags are deliberately + * ignored before it starts. Flags after `mcp` belong to the MCP server. + */ +function skipLeadingGlobalFlag( + cliArgs: readonly string[], + index: number +): number | undefined { + const { booleanFlags, valueFlags } = buildTopLevelFlags(); + const token = cliArgs[index] ?? ""; + const flag = token.split("=", 1)[0] ?? token; + + if (booleanFlags.has(flag)) { + return index + 1; + } + if (!valueFlags.has(flag)) { + return; + } + return token.includes("=") || cliArgs[index + 1] === undefined + ? index + 1 + : index + 2; +} + +export function getMcpArgs(cliArgs: readonly string[]): string[] | undefined { + for (let index = 0; index < cliArgs.length; ) { + const token = cliArgs[index] ?? ""; + if (token === "--") { + return; + } + if (!token.startsWith("-")) { + return token === "mcp" ? cliArgs.slice(index + 1) : undefined; + } + + const nextIndex = skipLeadingGlobalFlag(cliArgs, index); + if (nextIndex === undefined) { + return; + } + index = nextIndex; + } + + return; +} + +/** Run MCP and return whether the current invocation was handled by it. */ +async function runMcpCommand(cliArgs: string[]): Promise { + const mcpArgs = getMcpArgs(cliArgs); + if (!mcpArgs) { + return false; + } + + const [{ startMcpServer }, { getExitCode }] = await Promise.all([ + import("./lib/mcp.js"), + import("./lib/errors.js"), + ]); + + try { + await startMcpServer(mcpArgs); + } catch (mcpError) { + process.stderr.write(`${formatError(mcpError)}\n`); + process.exitCode = getExitCode(mcpError); + // MCP setup errors are terminal, unlike a running stdio server. Clean up + // network resources only on this error path so successful servers retain + // their dispatcher and are not force-exited on macOS. + const [{ scheduleForceExit }, { closeGlobalDispatcher }] = + await Promise.all([ + import("./lib/force-exit.js"), + import("./lib/close-dispatcher.js"), + ]); + scheduleForceExit(); + await closeGlobalDispatcher(); + } + + return true; +} + /** * Full CLI execution with telemetry, middleware, and error recovery. * @@ -236,6 +314,10 @@ type ErrorMiddleware = ( * `__complete` fast-path can skip them entirely. */ export async function runCli(cliArgs: string[]): Promise { + if (await runMcpCommand(cliArgs)) { + return; + } + const { isatty } = await import("node:tty"); const { ExitCode, run } = await import("@stricli/core"); const { app } = await import("./app.js"); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 000000000..7a770f881 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,22 @@ +import type { SentryContext } from "../context.js"; +import { buildCommand } from "../lib/command.js"; +import { CommandOutput } from "../lib/formatters/output.js"; + +/** Documentation route for the stdio handoff in {@link runCli}. */ +export const mcpCommand = buildCommand({ + auth: false, + docs: { + brief: "Start a local Sentry MCP server", + fullDescription: + "Start the local stdio MCP server using the current Sentry CLI session. " + + "Configure an MCP client with `sentry mcp`; authenticate first with `sentry auth login`.", + }, + output: { human: (message: string) => message }, + parameters: {}, + // biome-ignore lint/suspicious/useAwait: async generator required by buildCommand + async *func(this: SentryContext) { + yield new CommandOutput( + "The local MCP server is started by running `sentry mcp` from an MCP client configuration." + ); + }, +}); diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts new file mode 100644 index 000000000..b84f23ab4 --- /dev/null +++ b/packages/cli/src/lib/mcp.ts @@ -0,0 +1,95 @@ +import { getConfiguredSentryUrl } from "./constants.js"; +import { refreshToken } from "./db/auth.js"; +import { getEnv } from "./env.js"; +import { HostScopeError, ValidationError } from "./errors.js"; +import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; + +type McpServerConfig = { + sentryHost: string; + sentryProtocol: "http" | "https"; +}; + +function hasSentryTargetArg(args: readonly string[]): boolean { + return args.some( + (arg) => + arg === "--host" || + arg.startsWith("--host=") || + arg === "--url" || + arg.startsWith("--url=") + ); +} + +/** + * Translate the CLI's URL setting into MCP's host/protocol flags. + * + * The MCP parser intentionally rejects insecure SENTRY_URL values, while the + * CLI uses them for self-hosted defaults. Passing explicit flags preserves the + * selected CLI host without letting SENTRY_URL override --insecure-http. + */ +export function prepareMcpServerArgs( + args: readonly string[], + sentryUrl = getConfiguredSentryUrl() +): string[] { + if (!sentryUrl || hasSentryTargetArg(args)) { + return [...args]; + } + + // biome-ignore lint/plugin: invalid URLs are passed through to the MCP parser for its normal validation error. + try { + const url = new URL(sentryUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return [...args, `--url=${sentryUrl}`]; + } + return [ + ...args, + `--host=${url.host}`, + ...(url.protocol === "http:" ? ["--insecure-http"] : []), + ]; + } catch { + return [...args, `--url=${sentryUrl}`]; + } +} + +/** + * Resolve a credential for the local MCP server from the CLI's authenticated + * session, preserving the CLI's host-scoping protections. + */ +export async function resolveCliMcpAccessToken( + config: McpServerConfig +): Promise { + const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`; + const { token } = await refreshToken(); + const tokenHost = getActiveTokenHost(); + + if (!(tokenHost && isHostTrusted(targetUrl, tokenHost))) { + throw new HostScopeError( + "Cannot start MCP server with the active CLI credentials", + targetUrl, + tokenHost + ); + } + + return token; +} + +/** Start the local stdio server without introducing a second auth flow. */ +export async function startMcpServer(args: string[]): Promise { + if (args[0] === "auth") { + throw new ValidationError( + "Use `sentry auth` to manage credentials for `sentry mcp`." + ); + } + + const { runMcpServer } = await import("@sentry/mcp-server"); + const { + SENTRY_HOST: _sentryHost, + SENTRY_URL: _sentryUrl, + ...mcpEnv + } = getEnv(); + await runMcpServer(prepareMcpServerArgs(args), { + environment: mcpEnv, + packageName: "sentry mcp", + resolveAccessToken: resolveCliMcpAccessToken, + throwOnError: true, + }); +} diff --git a/packages/cli/test/e2e/bundle-setup.ts b/packages/cli/test/e2e/bundle-setup.ts index ac18e291f..04e3242f7 100644 --- a/packages/cli/test/e2e/bundle-setup.ts +++ b/packages/cli/test/e2e/bundle-setup.ts @@ -80,7 +80,9 @@ async function runBundleBuild(): Promise { } async function waitForBundle(): Promise { - const deadline = Date.now() + 55_000; + // Building the bundled CLI also builds its MCP runtime dependencies. On + // cold CI runners that can take longer than the old 55-second allowance. + const deadline = Date.now() + 115_000; while (Date.now() < deadline) { if (existsSync(BUNDLE_INDEX_PATH) && !existsSync(LOCK_DIR)) { return; diff --git a/packages/cli/test/e2e/bundle.test.ts b/packages/cli/test/e2e/bundle.test.ts index c188426ec..a91a91aab 100644 --- a/packages/cli/test/e2e/bundle.test.ts +++ b/packages/cli/test/e2e/bundle.test.ts @@ -51,7 +51,7 @@ const INK_APP_PATH = join(ROOT_DIR, "dist/ink-app.js"); describe("npm bundle", () => { beforeAll(async () => { await ensureBundleBuilt(); - }, 60_000); // Bundle can take a while + }, 120_000); // Cold CI builds include the MCP runtime dependencies test("bundle file exists", () => { expect(existsSync(BUNDLE_BIN_PATH)).toBe(true); diff --git a/packages/cli/test/e2e/library.test.ts b/packages/cli/test/e2e/library.test.ts index 6000e60ab..5f4d1b63f 100644 --- a/packages/cli/test/e2e/library.test.ts +++ b/packages/cli/test/e2e/library.test.ts @@ -92,7 +92,7 @@ describe("library mode (bundled)", () => { beforeAll(async () => { await ensureBundleBuilt(); - }, 60_000); + }, 120_000); // Cold CI builds include the MCP runtime dependencies // --- Bundle structure --- 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..931e9001c --- /dev/null +++ b/packages/cli/test/lib/mcp-auth.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { clearAuth, setAuthToken } from "../../src/lib/db/auth.js"; +import { AuthError, HostScopeError } from "../../src/lib/errors.js"; +import { + prepareMcpServerArgs, + resolveCliMcpAccessToken, + startMcpServer, +} from "../../src/lib/mcp.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +useTestConfigDir("mcp-auth-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +beforeEach(async () => { + await resetHostScopingState(); +}); + +afterEach(async () => { + await resetHostScopingState(); +}); + +describe("resolveCliMcpAccessToken", () => { + test("returns the active CLI session token for its scoped host", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.io", + }); + + await expect( + resolveCliMcpAccessToken({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }) + ).resolves.toBe("cli-session-token"); + }); + + test("refuses to use a CLI token for another Sentry host", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.example.com", + }); + + await expect( + resolveCliMcpAccessToken({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }) + ).rejects.toBeInstanceOf(HostScopeError); + }); + + test("directs an unauthenticated user to the CLI login flow", async () => { + await clearAuth(); + + await expect( + resolveCliMcpAccessToken({ + sentryHost: "sentry.io", + sentryProtocol: "https", + }) + ).rejects.toThrow(new AuthError("not_authenticated")); + }); + + test("does not expose a second MCP authentication flow", async () => { + await expect(startMcpServer(["auth", "login"])).rejects.toThrow( + "Use `sentry auth` to manage credentials for `sentry mcp`." + ); + }); +}); + +describe("prepareMcpServerArgs", () => { + test("uses SENTRY_HOST before SENTRY_URL for self-hosted instances", () => { + process.env.SENTRY_HOST = "http://sentry.internal:9000"; + process.env.SENTRY_URL = "https://sentry.example.com"; + + expect(prepareMcpServerArgs([])).toEqual([ + "--host=sentry.internal:9000", + "--insecure-http", + ]); + }); + + test("translates an insecure CLI URL into MCP host flags", () => { + expect(prepareMcpServerArgs([], "http://sentry.internal:9000")).toEqual([ + "--host=sentry.internal:9000", + "--insecure-http", + ]); + }); + + test("preserves an explicit MCP target over the CLI URL", () => { + expect( + prepareMcpServerArgs( + ["--host=sentry.example.com"], + "http://localhost:9000" + ) + ).toEqual(["--host=sentry.example.com"]); + }); +}); diff --git a/packages/cli/test/lib/mcp-command.test.ts b/packages/cli/test/lib/mcp-command.test.ts new file mode 100644 index 000000000..34cf8a68c --- /dev/null +++ b/packages/cli/test/lib/mcp-command.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "vitest"; +import { routes } from "../../src/app.js"; +import { getMcpArgs } from "../../src/cli.js"; + +test("exposes the local MCP server through the sentry CLI", () => { + expect( + routes.getAllEntries().some((entry) => entry.name.original === "mcp") + ).toBe(true); +}); + +test("recognizes MCP after leading global flags", () => { + expect(getMcpArgs(["--verbose", "mcp", "--host=sentry.example.com"])).toEqual( + ["--host=sentry.example.com"] + ); + expect(getMcpArgs(["--org", "acme", "mcp"])).toEqual([]); + expect(getMcpArgs(["issue", "mcp"])).toBeUndefined(); +}); diff --git a/packages/mcp-server/src/cli/usage.test.ts b/packages/mcp-server/src/cli/usage.test.ts new file mode 100644 index 000000000..77a50e784 --- /dev/null +++ b/packages/mcp-server/src/cli/usage.test.ts @@ -0,0 +1,39 @@ +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 index fbdc144df..c49fa4e6d 100644 --- a/packages/mcp-server/src/cli/usage.ts +++ b/packages/mcp-server/src/cli/usage.ts @@ -1,9 +1,64 @@ 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] diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index d89e305b5..f7aac9718 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -20,6 +20,8 @@ * ``` */ +import { pathToFileURL } from "node:url"; + import { getAgentProvider, getResolvedProviderType, @@ -36,26 +38,55 @@ 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 packageName = "@sentry/mcp-server"; +const defaultPackageName = "@sentry/mcp-server"; const allSkills = Object.keys(SKILLS) as ReadonlyArray< (typeof SKILLS)[keyof typeof SKILLS]["id"] >; -const usageText = buildUsage(packageName, allSkills); -function die(message: string): never { - console.error(message); - console.error(usageText); - process.exit(1); -} +export type McpServerOptions = { + /** + * Supplies credentials from a host application instead of the standalone + * device-code and cache flow. + */ + resolveAccessToken?: (config: PartiallyResolvedConfig) => 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, + }); -async function main() { - const rawArgs = process.argv.slice(2); + 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; } @@ -70,25 +101,29 @@ async function main() { process.exit(0); } if (cli.unknownArgs.length > 0) { - console.error("Error: Invalid argument(s):", cli.unknownArgs.join(", ")); - console.error(usageText); - process.exit(1); + die(new Error(`Error: Invalid argument(s): ${cli.unknownArgs.join(", ")}`)); } - const env = parseEnv(process.env); + const env = parseEnv(options.environment ?? process.env); const partialCfg = (() => { try { return finalize(merge(cli, env)); } catch (err) { - die(err instanceof Error ? err.message : String(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 resolveAccessToken(partialCfg).catch((err) => { - die(err instanceof Error ? err.message : String(err)); + const cfg = await (options.resolveAccessToken + ? options.resolveAccessToken(partialCfg).then((accessToken) => ({ + ...partialCfg, + accessToken, + })) + : resolveAccessToken(partialCfg) + ).catch((err) => { + die(err); }); // Configure embedded agent provider @@ -397,7 +432,12 @@ async function main() { }); } -main().catch((err) => { - console.error("Fatal error:", err); - process.exit(1); -}); +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/pnpm-lock.yaml b/pnpm-lock.yaml index c8c5e4f64..85536d031 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -289,6 +289,9 @@ importers: '@sentry/core': specifier: 10.63.0 version: 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) + '@sentry/mcp-server': + specifier: workspace:* + version: link:../mcp-server '@sentry/node': specifier: 10.65.0 version: 10.65.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))