diff --git a/README.md b/README.md index b149439c..c31f26bc 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,15 @@ Launch the transport: npx @sentry/mcp-server@latest --access-token=sentry-user-token ``` +If you've already authenticated with the [Sentry CLI](https://cli.sentry.dev/), +you can omit the token and the stdio server will reuse the token from +`~/.sentry/cli.db`: + +```shell +sentry auth +npx @sentry/mcp-server@latest +``` + Need to connect to a self-hosted deployment? Add --host (hostname only, e.g. --host=sentry.example.com) when you run the command. @@ -65,7 +74,7 @@ npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com --d #### Environment Variables ```shell -SENTRY_ACCESS_TOKEN= # Required: Your Sentry auth token +SENTRY_ACCESS_TOKEN= # Optional: explicit auth token override # LLM Provider Configuration (required for AI-powered search tools) EMBEDDED_AGENT_PROVIDER= # Required: 'openai' or 'anthropic' @@ -213,7 +222,7 @@ pnpm -w run cli --agent "who am I?" # Test against production pnpm -w run cli --mcp-host=https://mcp.sentry.dev "query" -# Test with local stdio mode (requires SENTRY_ACCESS_TOKEN) +# Test with local stdio mode (explicit token or Sentry CLI auth) pnpm -w run cli --access-token=TOKEN "query" ``` diff --git a/docs/releases/stdio.md b/docs/releases/stdio.md index 7cb958e4..3570947a 100644 --- a/docs/releases/stdio.md +++ b/docs/releases/stdio.md @@ -128,8 +128,14 @@ Add to `.cursor/mcp.json`: ## Environment Variables -Required: -- `SENTRY_ACCESS_TOKEN` - Sentry API access token +Authentication: +- `SENTRY_ACCESS_TOKEN` - Explicit Sentry API access token + +If the user has already authenticated with the +[Sentry CLI](https://cli.sentry.dev/commands/auth/), the stdio server can reuse +the token stored in `~/.sentry/cli.db` without requiring `SENTRY_ACCESS_TOKEN`. + +Host: - `SENTRY_HOST` - Sentry instance hostname (default: `sentry.io`) Optional: @@ -162,7 +168,7 @@ npm pack npm install -g ./sentry-mcp-server-1.2.3.tgz # Run stdio server -SENTRY_ACCESS_TOKEN=... @sentry/mcp-server +@sentry/mcp-server ``` ### Beta Releases diff --git a/docs/testing-stdio.md b/docs/testing-stdio.md index ff060e8a..dc399987 100644 --- a/docs/testing-stdio.md +++ b/docs/testing-stdio.md @@ -91,6 +91,11 @@ cd packages/mcp-server pnpm start --access-token=YOUR_TOKEN ``` +If you've already authenticated with the +[Sentry CLI](https://cli.sentry.dev/commands/auth/), you can omit +`--access-token` and the stdio server will reuse the token stored in +`~/.sentry/cli.db`. + This uses `tsx` to run TypeScript directly without building. ### Option 2: Using the Built Package (Production-like) @@ -103,6 +108,9 @@ 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 + +# Or reuse stored Sentry CLI auth +node packages/mcp-server/dist/index.js ``` ### Option 3: Using npx (End-user Experience) @@ -117,6 +125,9 @@ npx @sentry/mcp-server@latest --access-token=YOUR_TOKEN cd packages/mcp-server pnpm pack npx ./sentry-mcp-server-*.tgz --access-token=YOUR_TOKEN + +# Or reuse stored Sentry CLI auth +npx @sentry/mcp-server@latest ``` ## Testing with MCP Inspector @@ -206,7 +217,8 @@ The `mcp-test-client` package provides a CLI-based way to test the stdio transpo The CLI client automatically selects the transport based on flags: -- **Stdio transport**: `--access-token` flag provided +- **Stdio transport**: explicit token provided or + [Sentry CLI](https://cli.sentry.dev/commands/auth/)-managed auth state available - **Remote HTTP transport**: `--mcp-host` flag or no access token ### Basic Usage @@ -374,7 +386,7 @@ Add to `.vscode/settings.json`: ```bash # Basic usage ---access-token=TOKEN # Sentry access token (required) +--access-token=TOKEN # Optional explicit Sentry access token # Host configuration --host=sentry.example.com # Self-hosted Sentry (hostname only) @@ -497,18 +509,26 @@ pnpm build node dist/index.js --access-token=TOKEN ``` -### "Missing required parameter: access-token" +### "No access token was provided" **Cause:** No authentication provided. +Reuse stored auth by running +[Sentry CLI auth](https://cli.sentry.dev/commands/auth/) first: + **Solution:** ```bash -# Option 1: CLI flag +# Option 1: Reuse stored Sentry CLI auth +sentry auth +pnpm start + +# Option 2: CLI flag pnpm start --access-token=YOUR_TOKEN -# Option 2: Environment variable +# Option 3: Environment variable export SENTRY_ACCESS_TOKEN=YOUR_TOKEN pnpm start + ``` ### "AI-powered search tools unavailable" 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 f232d008..6aec8b3c 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx @@ -43,6 +43,12 @@ export default function StdioSetup() { Create a User Auth Token in your account settings with the following scopes:

+

+ If you've already authenticated with the{" "} + Sentry CLI, + you can skip the explicit token and the stdio server will reuse the + token stored in ~/.sentry/cli.db. +

AI-powered search: If you want the search_events and search_issues tools to @@ -89,7 +95,11 @@ export default function StdioSetup() {

--access-token / SENTRY_ACCESS_TOKEN
-
Required user auth token.
+
+ Explicit user auth token override. If omitted, the stdio server + falls back to Sentry CLI auth from ~/.sentry/cli.db + when available. +
--host / SENTRY_HOST diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index fa478a67..1d52612c 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -121,6 +121,10 @@ "types": "./dist/internal/agents/types.ts", "default": "./dist/internal/agents/types.js" }, + "./internal/sentry-cli-auth": { + "types": "./dist/internal/sentry-cli-auth.ts", + "default": "./dist/internal/sentry-cli-auth.js" + }, "./utils/url-utils": { "types": "./dist/utils/url-utils.ts", "default": "./dist/utils/url-utils.js" @@ -157,6 +161,7 @@ "@logtape/sentry": "^1.1.1", "@modelcontextprotocol/sdk": "catalog:", "@sentry/core": "catalog:", + "better-sqlite3": "catalog:", "ai": "catalog:", "dotenv": "catalog:", "zod": "catalog:" diff --git a/packages/mcp-core/src/internal/sentry-cli-auth.ts b/packages/mcp-core/src/internal/sentry-cli-auth.ts new file mode 100644 index 00000000..6fe6e15b --- /dev/null +++ b/packages/mcp-core/src/internal/sentry-cli-auth.ts @@ -0,0 +1,64 @@ +import Database from "better-sqlite3"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; + +export type SentryCliAccessTokenSource = "sentry_cli_db"; + +type AuthRow = { + token: string | null; + expires_at: number | null; +}; + +export function normalizeAccessToken( + token?: string | null, +): string | undefined { + const trimmed = token?.trim(); + return trimmed ? trimmed : undefined; +} + +export function getSentryCliDbPath(homeDir = homedir()): string { + return join(homeDir, ".sentry", "cli.db"); +} + +export function readAccessTokenFromSentryCliDb({ + nowMs, + homeDir, +}: { + nowMs: number; + homeDir?: string; +}): string | undefined { + const cliDbPath = getSentryCliDbPath(homeDir); + + try { + const db = new Database(cliDbPath, { + readonly: true, + fileMustExist: true, + }); + + try { + const row = db + .prepare("SELECT token, expires_at FROM auth WHERE id = 1") + .get() as AuthRow | undefined; + const token = normalizeAccessToken(row?.token); + + if (!token) { + return undefined; + } + + if ( + typeof row?.expires_at === "number" && + nowMs + TOKEN_EXPIRY_BUFFER_MS >= row.expires_at + ) { + return undefined; + } + + return token; + } finally { + db.close(); + } + } catch { + return undefined; + } +} diff --git a/packages/mcp-core/tsconfig.json b/packages/mcp-core/tsconfig.json index d4fe4fe1..1984152b 100644 --- a/packages/mcp-core/tsconfig.json +++ b/packages/mcp-core/tsconfig.json @@ -5,5 +5,6 @@ "outDir": "dist", "rootDir": "src" }, + "files": ["../mcp-server-tsconfig/better-sqlite3.d.ts"], "include": ["src"] } diff --git a/packages/mcp-core/tsdown.config.ts b/packages/mcp-core/tsdown.config.ts index 3cdb3e43..ce1c78b2 100644 --- a/packages/mcp-core/tsdown.config.ts +++ b/packages/mcp-core/tsdown.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ external: [ // Keep workspace dependencies external (don't bundle them) "@sentry/mcp-server-mocks", + "better-sqlite3", ], env: { DEFAULT_SENTRY_DSN: diff --git a/packages/mcp-server-tsconfig/better-sqlite3.d.ts b/packages/mcp-server-tsconfig/better-sqlite3.d.ts new file mode 100644 index 00000000..4c8e01dc --- /dev/null +++ b/packages/mcp-server-tsconfig/better-sqlite3.d.ts @@ -0,0 +1,20 @@ +declare module "better-sqlite3" { + export type DatabaseOptions = { + readonly?: boolean; + fileMustExist?: boolean; + }; + + export type Statement> = { + get(...params: unknown[]): Result | undefined; + run(...params: unknown[]): unknown; + }; + + export default class Database { + constructor(filename: string, options?: DatabaseOptions); + prepare>( + source: string, + ): Statement; + exec(source: string): this; + close(): this; + } +} diff --git a/packages/mcp-server-tsconfig/package.json b/packages/mcp-server-tsconfig/package.json index ab5d8e96..505db09c 100644 --- a/packages/mcp-server-tsconfig/package.json +++ b/packages/mcp-server-tsconfig/package.json @@ -2,8 +2,5 @@ "name": "@sentry/mcp-server-tsconfig", "version": "0.29.0", "private": true, - "files": [ - "tsconfig.base.json", - "tsconfig.vite.json" - ] + "files": ["better-sqlite3.d.ts", "tsconfig.base.json", "tsconfig.vite.json"] } diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index ac7b0efe..230f0450 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -13,11 +13,7 @@ "license": "FSL-1.1-ALv2", "author": "Sentry", "homepage": "https://github.com/getsentry/sentry-mcp", - "keywords": [ - "sentry", - "mcp", - "model-context-protocol" - ], + "keywords": ["sentry", "mcp", "model-context-protocol"], "bugs": { "url": "https://github.com/getsentry/sentry-mcp/issues" }, @@ -28,9 +24,7 @@ "bin": { "sentry-mcp": "./dist/index.js" }, - "files": [ - "./dist/*" - ], + "files": ["./dist/*"], "exports": { ".": { "types": "./dist/index.ts", @@ -53,6 +47,7 @@ "@modelcontextprotocol/sdk": "catalog:", "@sentry/node": "catalog:", "@sentry/core": "catalog:", + "better-sqlite3": "catalog:", "dotenv": "catalog:", "zod": "catalog:" }, diff --git a/packages/mcp-server/src/cli/auth.test.ts b/packages/mcp-server/src/cli/auth.test.ts new file mode 100644 index 00000000..5a9d5a6f --- /dev/null +++ b/packages/mcp-server/src/cli/auth.test.ts @@ -0,0 +1,118 @@ +import Database from "better-sqlite3"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveAccessToken } from "./auth"; + +function createCliDb({ + token, + expiresAt, +}: { + token?: string | null; + expiresAt?: number | null; +}) { + const homeDir = mkdtempSync(join(tmpdir(), "sentry-mcp-auth-")); + const configDir = join(homeDir, ".sentry"); + mkdirSync(configDir); + const dbPath = join(configDir, "cli.db"); + const db = new Database(dbPath); + + db.exec(` + CREATE TABLE auth ( + id INTEGER PRIMARY KEY CHECK (id = 1), + token TEXT, + refresh_token TEXT, + expires_at INTEGER, + issued_at INTEGER, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + ); + `); + + db.prepare("INSERT INTO auth (id, token, expires_at) VALUES (1, ?, ?)").run( + token ?? null, + expiresAt ?? null, + ); + db.close(); + + return homeDir; +} + +const tempDirs: string[] = []; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe("resolveAccessToken", () => { + it("prefers explicit access token over env and cli db", () => { + const nowMs = Date.now(); + const configDir = createCliDb({ + token: "db-token", + expiresAt: nowMs + 10 * 60_000, + }); + tempDirs.push(configDir); + + const result = resolveAccessToken({ + accessToken: "flag-token", + nowMs, + homeDir: configDir, + }); + + expect(result).toEqual({ + accessToken: "flag-token", + source: "flag-or-env", + }); + }); + + it("uses cli.db when explicit token is not provided", () => { + const nowMs = Date.now(); + const configDir = createCliDb({ + token: "db-token", + expiresAt: nowMs + 10 * 60_000, + }); + tempDirs.push(configDir); + + const result = resolveAccessToken({ + nowMs, + homeDir: configDir, + }); + + expect(result).toEqual({ + accessToken: "db-token", + source: "sentry_cli_db", + }); + }); + + it("ignores expired cli.db tokens", () => { + const nowMs = Date.now(); + const configDir = createCliDb({ + token: "db-token", + expiresAt: nowMs + 4 * 60_000, + }); + tempDirs.push(configDir); + + const result = resolveAccessToken({ + nowMs, + homeDir: configDir, + }); + + expect(result).toEqual({}); + }); + + it("ignores missing or unreadable cli.db state", () => { + const homeDir = mkdtempSync(join(tmpdir(), "sentry-mcp-auth-")); + const configDir = join(homeDir, ".sentry"); + mkdirSync(configDir); + tempDirs.push(homeDir); + writeFileSync(join(configDir, "cli.db"), "not-a-sqlite-db"); + + const result = resolveAccessToken({ + homeDir, + }); + + expect(result).toEqual({}); + }); +}); diff --git a/packages/mcp-server/src/cli/auth.ts b/packages/mcp-server/src/cli/auth.ts new file mode 100644 index 00000000..e9605776 --- /dev/null +++ b/packages/mcp-server/src/cli/auth.ts @@ -0,0 +1,45 @@ +import { + getSentryCliDbPath, + normalizeAccessToken, + readAccessTokenFromSentryCliDb, + type SentryCliAccessTokenSource, +} from "@sentry/mcp-core/internal/sentry-cli-auth"; + +export type AccessTokenSource = "flag-or-env" | SentryCliAccessTokenSource; + +export type ResolvedAccessToken = { + accessToken?: string; + source?: AccessTokenSource; +}; + +export function getCliDbPath(homeDir?: string): string { + return getSentryCliDbPath(homeDir); +} + +export function resolveAccessToken({ + accessToken, + nowMs = Date.now(), + homeDir, +}: { + accessToken?: string; + nowMs?: number; + homeDir?: string; +}): ResolvedAccessToken { + const normalizedAccessToken = normalizeAccessToken(accessToken); + if (normalizedAccessToken) { + return { + accessToken: normalizedAccessToken, + source: "flag-or-env", + }; + } + + const cliDbToken = readAccessTokenFromSentryCliDb({ nowMs, homeDir }); + if (cliDbToken) { + return { + accessToken: cliDbToken, + source: "sentry_cli_db", + }; + } + + return {}; +} diff --git a/packages/mcp-server/src/cli/resolve.ts b/packages/mcp-server/src/cli/resolve.ts index dbf934f9..7bdda586 100644 --- a/packages/mcp-server/src/cli/resolve.ts +++ b/packages/mcp-server/src/cli/resolve.ts @@ -18,7 +18,7 @@ export function finalize(input: MergedArgs): ResolvedConfig { // Access token required if (!input.accessToken) { throw new Error( - "Error: No access token was provided. Pass one with `--access-token` or via `SENTRY_ACCESS_TOKEN`.", + "Error: No access token was provided. Pass one with `--access-token`, via `SENTRY_ACCESS_TOKEN`, or authenticate with `sentry auth`.", ); } diff --git a/packages/mcp-server/src/cli/usage.ts b/packages/mcp-server/src/cli/usage.ts index d7887f85..da891125 100644 --- a/packages/mcp-server/src/cli/usage.ts +++ b/packages/mcp-server/src/cli/usage.ts @@ -4,10 +4,12 @@ export function buildUsage( packageName: string, allSkills: ReadonlyArray, ): string { - return `Usage: ${packageName} --access-token= [--host=] + return `Usage: ${packageName} [--access-token=] [--host=] -Required: +Authentication: --access-token Sentry User Auth Token with API access + If omitted, falls back to SENTRY_ACCESS_TOKEN, + or \`sentry auth\` state. Common optional flags: --host Change Sentry host (self-hosted) @@ -34,12 +36,14 @@ All skills: ${allSkills.join(", ")} Environment variables: SENTRY_ACCESS_TOKEN Sentry auth token (alternative to --access-token) + SENTRY_URL Full Sentry HTTPS URL (alternative to SENTRY_HOST) OPENAI_API_KEY OpenAI API key for AI-powered search tools ANTHROPIC_API_KEY Anthropic API key for AI-powered search tools EMBEDDED_AGENT_PROVIDER Provider override: openai or anthropic MCP_DISABLE_SKILLS Disable specific skills (comma-separated) Examples: + ${packageName} ${packageName} --access-token=TOKEN ${packageName} --access-token=TOKEN --skills=inspect,triage ${packageName} --access-token=TOKEN --host=sentry.example.com diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 049edd14..16010a1b 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -21,6 +21,7 @@ import { LIB_VERSION } from "@sentry/mcp-core/version"; import { buildUsage } from "./cli/usage"; import { parseArgv, parseEnv, merge } from "./cli/parse"; import { finalize } from "./cli/resolve"; +import { getCliDbPath, resolveAccessToken } from "./cli/auth"; import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; import { SKILLS } from "@sentry/mcp-core/skills"; import { @@ -58,7 +59,22 @@ if (cli.unknownArgs.length > 0) { const env = parseEnv(process.env); const cfg = (() => { try { - return finalize(merge(cli, env)); + const merged = merge(cli, env); + const resolvedAccessToken = resolveAccessToken({ + accessToken: merged.accessToken, + }); + + if (resolvedAccessToken.source === "sentry_cli_db") { + console.warn( + `Using access token from Sentry CLI auth state in ${getCliDbPath()}.`, + ); + console.warn(""); + } + + return finalize({ + ...merged, + accessToken: resolvedAccessToken.accessToken, + }); } catch (err) { die(err instanceof Error ? err.message : String(err)); } diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json index 07eb7e9f..c2bfcc30 100644 --- a/packages/mcp-server/tsconfig.json +++ b/packages/mcp-server/tsconfig.json @@ -4,6 +4,7 @@ "outDir": "./dist", "rootDir": "./src" }, + "files": ["../mcp-server-tsconfig/better-sqlite3.d.ts"], "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/packages/mcp-server/tsdown.config.ts b/packages/mcp-server/tsdown.config.ts index e52edf49..15e32415 100644 --- a/packages/mcp-server/tsdown.config.ts +++ b/packages/mcp-server/tsdown.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ external: [ // Only mark test-only packages as external "@sentry/mcp-server-mocks", + "better-sqlite3", // Everything else (including @sentry/mcp-core) will be bundled ], env: { diff --git a/packages/mcp-test-client/README.md b/packages/mcp-test-client/README.md index 3072d768..514584fa 100644 --- a/packages/mcp-test-client/README.md +++ b/packages/mcp-test-client/README.md @@ -54,7 +54,7 @@ Create a `.env` file in the package directory: # Required OPENAI_API_KEY=your_openai_api_key -# Required - Sentry access token with appropriate permissions +# Optional - explicit auth token override SENTRY_ACCESS_TOKEN=your_sentry_access_token # Optional (self-hosted only) @@ -84,7 +84,8 @@ The client automatically determines the connection mode: 1. Command-line flag (`--access-token`) 2. Environment variable (`SENTRY_ACCESS_TOKEN`) -3. `.env` file +3. [Sentry CLI](https://cli.sentry.dev/) auth state in `~/.sentry/cli.db` +4. `.env` file **Remote Mode (HTTP streaming)**: Used when no access token is provided, prompts for OAuth authentication @@ -101,7 +102,7 @@ Your Sentry access token needs the following scopes: ## Usage -### Remote Mode (Default) +### Remote Mode Connect to the remote MCP server via HTTP streaming (uses OAuth for authentication): @@ -117,7 +118,9 @@ pnpm mcp-test-client --mcp-host http://localhost:8787 ### Local Mode -Use the local stdio transport by providing a Sentry access token: +Use the local stdio transport by providing a Sentry access token, or by +authenticating with the [Sentry CLI](https://cli.sentry.dev/commands/auth/) +first: ```bash # Using environment variable @@ -125,6 +128,10 @@ SENTRY_ACCESS_TOKEN=your_token pnpm mcp-test-client # Using command line flag pnpm mcp-test-client --access-token your_token + +# Using stored Sentry CLI auth state +sentry auth +pnpm mcp-test-client ``` ### Interactive Mode (Default) @@ -206,8 +213,8 @@ If you see "Failed to connect to MCP server": If you get authentication errors: 1. Verify your OPENAI_API_KEY is set correctly -2. Check that your SENTRY_ACCESS_TOKEN has the required permissions -3. For self-hosted Sentry, ensure SENTRY_HOST is set +2. Check that your token source (`SENTRY_ACCESS_TOKEN` or [Sentry CLI](https://cli.sentry.dev/commands/auth/) auth) has the required permissions +3. For self-hosted Sentry, ensure `SENTRY_HOST` is set ### Tool Errors diff --git a/packages/mcp-test-client/package.json b/packages/mcp-test-client/package.json index 180ac410..b80d443a 100644 --- a/packages/mcp-test-client/package.json +++ b/packages/mcp-test-client/package.json @@ -24,6 +24,7 @@ "@sentry/mcp-core": "workspace:*", "@sentry/node": "catalog:", "ai": "catalog:", + "better-sqlite3": "catalog:", "chalk": "catalog:", "commander": "catalog:", "dotenv": "catalog:", diff --git a/packages/mcp-test-client/src/index.ts b/packages/mcp-test-client/src/index.ts index 626f7eab..177be195 100644 --- a/packages/mcp-test-client/src/index.ts +++ b/packages/mcp-test-client/src/index.ts @@ -13,6 +13,7 @@ import { connectToRemoteMCPServer } from "./mcp-test-client-remote.js"; import { runAgent } from "./agent.js"; import { logError, logInfo } from "./logger.js"; import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry"; +import { resolveLocalAuth } from "./stdio-auth.js"; import type { MCPConnection } from "./types.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -80,17 +81,17 @@ program : "production"), }); - // Check for access token in priority order - const accessToken = - options.accessToken || process.env.SENTRY_ACCESS_TOKEN; - const sentryHost = process.env.SENTRY_HOST; + const localAuth = resolveLocalAuth({ + accessToken: options.accessToken || process.env.SENTRY_ACCESS_TOKEN, + env: process.env, + }); const openaiKey = process.env.OPENAI_API_KEY; // Determine mode based on access token availability // Local mode (stdio transport) when access token is provided // Remote mode (SSE transport with OAuth) when no access token - const useLocalMode = !!accessToken; + const useLocalMode = !!localAuth.accessToken; if (!openaiKey) { logError("OPENAI_API_KEY environment variable is required"); @@ -105,9 +106,13 @@ program let connection: MCPConnection; if (useLocalMode) { // Use local stdio transport when access token is provided + if (localAuth.accessTokenSource === "sentry_cli_db") { + logInfo("Authenticated with Sentry", "using Sentry CLI auth state"); + } + connection = await connectToMCPServer({ - accessToken, - host: sentryHost || process.env.SENTRY_HOST, + accessToken: localAuth.accessToken, + host: localAuth.host, sentryDsn: sentryDsn, useAgentEndpoint: options.agent, useExperimental: options.experimental, @@ -116,7 +121,7 @@ program // Use remote SSE transport when no access token connection = await connectToRemoteMCPServer({ mcpHost: options.mcpHost, - accessToken: accessToken, + accessToken: localAuth.accessToken, useAgentEndpoint: options.agent, useExperimental: options.experimental, }); diff --git a/packages/mcp-test-client/src/stdio-auth.test.ts b/packages/mcp-test-client/src/stdio-auth.test.ts new file mode 100644 index 00000000..202d907b --- /dev/null +++ b/packages/mcp-test-client/src/stdio-auth.test.ts @@ -0,0 +1,116 @@ +import Database from "better-sqlite3"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveLocalAuth, resolveSentryHost } from "./stdio-auth.js"; + +function createCliDb({ + token, + expiresAt, +}: { + token?: string | null; + expiresAt?: number | null; +}) { + const homeDir = mkdtempSync(join(tmpdir(), "sentry-mcp-client-auth-")); + const configDir = join(homeDir, ".sentry"); + mkdirSync(configDir); + const dbPath = join(configDir, "cli.db"); + const db = new Database(dbPath); + + db.exec(` + CREATE TABLE auth ( + id INTEGER PRIMARY KEY CHECK (id = 1), + token TEXT, + refresh_token TEXT, + expires_at INTEGER, + issued_at INTEGER, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + ); + `); + + db.prepare("INSERT INTO auth (id, token, expires_at) VALUES (1, ?, ?)").run( + token ?? null, + expiresAt ?? null, + ); + db.close(); + + return homeDir; +} + +const tempDirs: string[] = []; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe("resolveSentryHost", () => { + it("falls back to SENTRY_HOST", () => { + expect( + resolveSentryHost({ + SENTRY_HOST: "sentry.example.com", + }), + ).toBe("sentry.example.com"); + }); +}); + +describe("resolveLocalAuth", () => { + it("uses cli.db when explicit token is missing", () => { + const nowMs = Date.now(); + const configDir = createCliDb({ + token: "db-token", + expiresAt: nowMs + 10 * 60_000, + }); + tempDirs.push(configDir); + + const result = resolveLocalAuth({ + env: { + SENTRY_HOST: "sentry.example.com", + }, + nowMs, + homeDir: configDir, + }); + + expect(result).toEqual({ + accessToken: "db-token", + accessTokenSource: "sentry_cli_db", + host: "sentry.example.com", + }); + }); + + it("stays in remote mode when cli.db token is expired", () => { + const nowMs = Date.now(); + const configDir = createCliDb({ + token: "db-token", + expiresAt: nowMs + 4 * 60_000, + }); + tempDirs.push(configDir); + + const result = resolveLocalAuth({ + env: { + SENTRY_HOST: "sentry.example.com", + }, + nowMs, + homeDir: configDir, + }); + + expect(result).toEqual({}); + }); + + it("ignores unreadable cli.db state", () => { + const homeDir = mkdtempSync(join(tmpdir(), "sentry-mcp-client-auth-")); + const configDir = join(homeDir, ".sentry"); + mkdirSync(configDir); + tempDirs.push(homeDir); + writeFileSync(join(configDir, "cli.db"), "not-a-sqlite-db"); + + const result = resolveLocalAuth({ + env: {}, + homeDir, + }); + + expect(result).toEqual({}); + }); +}); diff --git a/packages/mcp-test-client/src/stdio-auth.ts b/packages/mcp-test-client/src/stdio-auth.ts new file mode 100644 index 00000000..07c87529 --- /dev/null +++ b/packages/mcp-test-client/src/stdio-auth.ts @@ -0,0 +1,56 @@ +import { + normalizeAccessToken, + readAccessTokenFromSentryCliDb, + type SentryCliAccessTokenSource, +} from "@sentry/mcp-core/internal/sentry-cli-auth"; +import { validateSentryHostThrows } from "@sentry/mcp-core/utils/url-utils"; + +export type AccessTokenSource = "flag-or-env" | SentryCliAccessTokenSource; + +export type ResolvedLocalAuth = { + accessToken?: string; + accessTokenSource?: AccessTokenSource; + host?: string; +}; + +export function resolveSentryHost(env: NodeJS.ProcessEnv): string | undefined { + const sentryHost = env.SENTRY_HOST?.trim(); + if (sentryHost) { + validateSentryHostThrows(sentryHost); + return sentryHost; + } + + return undefined; +} + +export function resolveLocalAuth({ + accessToken, + env, + nowMs = Date.now(), + homeDir, +}: { + accessToken?: string; + env: NodeJS.ProcessEnv; + nowMs?: number; + homeDir?: string; +}): ResolvedLocalAuth { + const normalizedAccessToken = normalizeAccessToken(accessToken); + if (normalizedAccessToken) { + return { + accessToken: normalizedAccessToken, + accessTokenSource: "flag-or-env", + host: resolveSentryHost(env), + }; + } + + const cliDbToken = readAccessTokenFromSentryCliDb({ nowMs, homeDir }); + if (cliDbToken) { + return { + accessToken: cliDbToken, + accessTokenSource: "sentry_cli_db", + host: resolveSentryHost(env), + }; + } + + return {}; +} diff --git a/packages/mcp-test-client/tsconfig.json b/packages/mcp-test-client/tsconfig.json index 4c1f2efa..0c68e0f9 100644 --- a/packages/mcp-test-client/tsconfig.json +++ b/packages/mcp-test-client/tsconfig.json @@ -4,6 +4,7 @@ "outDir": "./dist", "rootDir": "./src" }, + "files": ["../mcp-server-tsconfig/better-sqlite3.d.ts"], "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] -} \ No newline at end of file +} diff --git a/packages/mcp-test-client/tsdown.config.ts b/packages/mcp-test-client/tsdown.config.ts index 75457e93..cdf3837c 100644 --- a/packages/mcp-test-client/tsdown.config.ts +++ b/packages/mcp-test-client/tsdown.config.ts @@ -7,6 +7,7 @@ const packageVersion = export default defineConfig({ entry: ["src/index.ts"], + external: ["better-sqlite3"], format: ["esm"], clean: true, platform: "node", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3005c8bc..43c0295f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,6 +379,9 @@ importers: ai: specifier: 'catalog:' version: 6.0.64(zod@3.25.76) + better-sqlite3: + specifier: 'catalog:' + version: 11.10.0 dotenv: specifier: 'catalog:' version: 16.6.1 @@ -422,6 +425,9 @@ importers: '@sentry/node': specifier: 'catalog:' version: 10.35.0 + better-sqlite3: + specifier: 'catalog:' + version: 11.10.0 dotenv: specifier: 'catalog:' version: 16.6.1 @@ -537,6 +543,9 @@ importers: ai: specifier: 'catalog:' version: 6.0.64(zod@3.25.76) + better-sqlite3: + specifier: 'catalog:' + version: 11.10.0 chalk: specifier: 'catalog:' version: 5.4.1 @@ -4006,6 +4015,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prettier@3.6.2: