diff --git a/docs/operations/security.md b/docs/operations/security.md index 91c811541..43dccb4b1 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -58,21 +58,27 @@ not mean the same thing. ### Upstream Sentry Scopes -When we redirect to Sentry OAuth, we always request the shared Sentry scope set -defined in `packages/mcp-core/src/scopes.ts`: +When we redirect to Sentry OAuth, we request only the scopes needed by the +tools the user's selected skills enable. `getScopesForSkills()` in +`packages/mcp-core/src/skills.ts` computes them from each tool's +`requiredScopes`, plus the default read scopes +(`org:read project:read team:read event:read`). + +For example, approving only **Inspect Issues & Events** requests: ```text -org:read project:write team:write event:write alerts:write +event:read org:read project:read team:read ``` -We ask Sentry for this broader shared token because: - -1. Sentry OAuth scopes are coarse compared to our MCP capability model. -2. A single upstream token must support every tool that could be enabled for the granted MCP skills. -3. The worker reuses the same upstream token across later MCP token refreshes and MCP requests for that grant. +Triage, Seer, and Manage Projects & Teams add the write scopes their tools need +(for example `event:write` for triage and Seer, and `project:write`, +`team:write`, and `alerts:write` for project management). -This token is therefore a server-side capability token for talking to Sentry, -not the final permission boundary presented to the MCP client. +The upstream token is still a server-side capability token for talking to +Sentry. It is reused across MCP token refreshes for the same grant, so the +skills chosen at consent time fix its scopes. Granting more skills later goes +through the OAuth flow again, and Sentry asks the user to approve any new +scopes. ### Downstream MCP Restrictions @@ -122,7 +128,7 @@ The remote deployment intentionally separates trust boundaries: - All Sentry API access happens server-side 3. **Session narrowing** - - Broad upstream Sentry scopes are narrowed by MCP-side skills and URL constraints + - Upstream Sentry scopes match the granted skills, and MCP-side skills and URL constraints narrow access further - A client may hold a valid MCP token but still be unable to access tools or resources outside the granted session shape 4. **Revocation on upstream failure** @@ -182,7 +188,7 @@ interface ServerContext { userIpAddress?: string; clientId: string; accessToken: string; - grantedSkills: Set; // Primary authorization method + grantedSkills: Set; // Primary authorization method // grantedScopes is deprecated and will be removed Jan 1, 2026 constraints: Constraints; sentryHost: string; @@ -240,7 +246,7 @@ Example protection in tools: ```typescript // URLs must be valid and from expected domains -if (!issueUrl.includes('sentry.io')) { +if (!issueUrl.includes("sentry.io")) { throw new UserInputError("Invalid Sentry URL"); } @@ -257,10 +263,10 @@ The OAuth `state` is a compact HMAC-signed payload with a 10‑minute expiry: type OAuthState = { clientId: string; redirectUri: string; // must be a valid URL - scope: string[]; // from OAuth provider parseAuthRequest + scope: string[]; // from OAuth provider parseAuthRequest permissions?: string[]; // user selections from approval - iat: number; // issued at (ms) - exp: number; // expires at (ms) + iat: number; // issued at (ms) + exp: number; // expires at (ms) }; // Sign: `${hex(hmacSHA256(json))}.${btoa(json)}` using COOKIE_SECRET @@ -285,11 +291,11 @@ All user inputs sanitized: // Signed cookie for approved clients const cookie = await signCookie( `approved_clients=${JSON.stringify(approvedClients)}`, - COOKIE_SECRET + COOKIE_SECRET, ); // Cookie attributes -"HttpOnly; Secure; SameSite=Lax; Max-Age=2592000" // 30 days +("HttpOnly; Secure; SameSite=Lax; Max-Age=2592000"); // 30 days ``` ## Error Handling @@ -324,7 +330,7 @@ catch (error) { ```typescript // Verify organization access const { organizations } = await apiService.listOrganizations(); -if (!organizations.find(org => org.slug === requestedOrg)) { +if (!organizations.find((org) => org.slug === requestedOrg)) { throw new UserInputError("No access to organization"); } ``` @@ -335,7 +341,7 @@ Required for OAuth: ```bash SENTRY_CLIENT_ID=your_oauth_app_id -SENTRY_CLIENT_SECRET=your_oauth_app_secret +SENTRY_CLIENT_SECRET=your_oauth_app_secret COOKIE_SECRET=random_32_char_string ``` @@ -343,10 +349,7 @@ COOKIE_SECRET=random_32_char_string ```typescript // Allowed origins for OAuth flow -const ALLOWED_ORIGINS = [ - "https://sentry.io", - "https://*.sentry.io" -]; +const ALLOWED_ORIGINS = ["https://sentry.io", "https://*.sentry.io"]; ``` ## References diff --git a/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts b/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts index 5dffe42e7..8b203dcd4 100644 --- a/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts +++ b/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts @@ -1,6 +1,7 @@ import { AuthorizationError } from "@cloudflare/workers-oauth-provider"; import { Hono } from "hono"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { type Skill, getScopesForSkills } from "@sentry/mcp-core/skills"; import { SKILL_PREFERENCES_COOKIE_NAME } from "../lib/approval-dialog"; import type { Env } from "../types"; import oauthRoute from "./index"; @@ -466,6 +467,80 @@ describe("oauth authorize routes", () => { expect((decodedState.req as any).scope).toEqual(["read", "write"]); }); + describe("upstream Sentry scopes match selected skills", () => { + async function upstreamScopesFor(skills: string[]): Promise { + const formData = new FormData(); + const signedState = await signState( + { + req: { + oauthReqInfo: { + clientId: "test-client", + redirectUri: "https://example.com/callback", + scope: ["read"], + state: "original-state", + }, + }, + iat: Date.now(), + exp: Date.now() + 10 * 60 * 1000, + }, + testEnv.COOKIE_SECRET!, + ); + formData.append("state", signedState); + for (const skill of skills) { + formData.append("skill", skill); + } + const response = await app.fetch( + new Request("http://localhost/oauth/authorize", { + method: "POST", + body: formData, + }), + testEnv as Env, + ); + expect(response.status).toBe(302); + const redirectUrl = new URL(response.headers.get("location")!); + expect(redirectUrl.pathname).toBe("/oauth/authorize/"); + return (redirectUrl.searchParams.get("scope") ?? "") + .split(" ") + .filter(Boolean) + .sort(); + } + + it("requests exactly the read scopes from Sentry when only 'inspect' is selected", async () => { + expect(await upstreamScopesFor(["inspect"])).toEqual([ + "event:read", + "org:read", + "project:read", + "team:read", + ]); + }); + + it("requests event:write when Seer is selected, for starting autofix", async () => { + expect(await upstreamScopesFor(["inspect", "seer"])).toEqual([ + "event:read", + "event:write", + "org:read", + "project:read", + "team:read", + ]); + }); + + it.each([ + [["inspect"]], + [["inspect", "seer"]], + [["inspect", "triage"]], + [["inspect", "project-management"]], + ])( + "requests exactly getScopesForSkills(%j) from Sentry", + async (skills) => { + const expected = Array.from( + await getScopesForSkills(new Set(skills as Skill[])), + ).sort(); + + expect(await upstreamScopesFor(skills)).toEqual(expected); + }, + ); + }); + it("uses submitted skills rather than remembered skills for redirect state", async () => { mockOAuthProvider.lookupClient.mockResolvedValue({ clientId: "test-client", diff --git a/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts b/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts index 0fe38f453..ce11322d3 100644 --- a/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts +++ b/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts @@ -3,9 +3,9 @@ import { type AuthRequest, } from "@cloudflare/workers-oauth-provider"; import * as Sentry from "@sentry/cloudflare"; +import { getScopesForSkills, parseSkills } from "@sentry/mcp-core/skills"; import { logWarn } from "@sentry/mcp-core/telem/logging"; import { Hono } from "hono"; -import { SCOPES } from "../../../constants"; import { getRememberedSkillsForClient, parseRedirectApproval, @@ -38,10 +38,24 @@ interface AuthRequestWithSkills extends AuthRequest { resource?: string; } +/** + * Sentry API scopes to request upstream for the skills the user approved. + * + * Only ask Sentry for what the enabled tools need, so the consent screen and + * the resulting upstream token match the selected skills. + */ +async function getUpstreamScope(skills: unknown): Promise { + const { valid } = parseSkills(skills); + return Array.from(await getScopesForSkills(valid)) + .sort() + .join(" "); +} + async function redirectToUpstream( env: Env, request: Request, oauthReqInfo: AuthRequest | AuthRequestWithSkills, + scope: string, headers: HeadersInit = {}, stateOverride?: string, ) { @@ -53,7 +67,7 @@ async function redirectToUpstream( SENTRY_AUTH_URL, `https://${env.SENTRY_HOST || "sentry.io"}`, ).href, - scope: Object.keys(SCOPES).join(" "), + scope, client_id: env.SENTRY_CLIENT_ID, redirect_uri: new URL("/oauth/callback", request.url).href, state: stateOverride ?? btoa(JSON.stringify(oauthReqInfo)), @@ -379,6 +393,7 @@ export default new Hono<{ Bindings: Env }>() c.env, c.req.raw, oauthReqWithSkills, + await getUpstreamScope(skills), headers, signedState, ); diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index b6c491fc1..d1dac7149 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -175,12 +175,12 @@ { "name": "get_span_details", "description": "Get detailed information about a specific span within a Sentry trace.\n\nUse this tool when you need to:\n- Inspect a span when you already know its trace ID and span ID\n- Focus a trace investigation on one operation\n- Review a span's attributes, timing, errors, and child spans\n\n\nget_span_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a', spanId='aa8e7f3384ef4ff5')\n\n\n\n- Pass traceId and spanId as separate parameters.\n- Use get_trace_details for a trace overview when no span is selected.\n- Use get_sentry_resource when starting from a Sentry trace or span URL.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read"] }, { "name": "get_trace_details", "description": "Get detailed information about a specific Sentry trace by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')\n- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'\n- Want high-level overview and link to view trace details in Sentry\n- Need trace statistics and span breakdown\n- Want an overview first, then a guided pivot into additional spans or events\n\nDO NOT USE for:\n- General searching for traces (use search_traces)\n- Complete span enumeration or branch-by-branch reconstruction (use search_traces scoped to the trace)\n\nTRIGGER PATTERNS:\n- 'Show me trace abc123' → use get_trace_details\n- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details\n- 'What is trace [trace-id]' → use get_trace_details\n\n\n### Get trace overview\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')\n```\n\n### Focus a single span\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a', spanId='aa8e7f3384ef4ff5')\n```\n\n\n\n- Trace IDs are 32-character hexadecimal strings\n- This returns a condensed trace overview, not a full span dump\n- Provide `spanId` to focus on a single span within the trace\n- If the response says it shows a subset of spans, use search_traces to inspect the rest of the trace\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read"] }, { "name": "get_uptime_monitor_details", @@ -200,7 +200,7 @@ { "name": "search_errors", "description": "Search Sentry error events: exceptions and crashes with stack traces. Use for error counts, statistics, trends, and individual error events.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('how many errors today', 'top error types'), individual events ('latest TypeErrors in checkout'), and time series ('errors per hour last 24h').\n\nNOT for log messages, including error/warning logs (use search_logs), requests or latency (use search_traces), or grouped issue lists (use search_issues).\n\n\nsearch_errors(organizationSlug='my-org', query='how many errors today')\nsearch_errors(organizationSlug='my-org', query='most common error types in production this week')\nsearch_errors(organizationSlug='my-org', query='errors per hour last 24h')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_issue_events", @@ -215,27 +215,27 @@ { "name": "search_logs", "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_metrics", "description": "Search Sentry metrics: counters, gauges, and distributions, as rows or aggregates. Use for metric values, percentiles, totals, and trends.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('p95 http.request.duration by environment'), individual metric rows, and time series ('total tokens per day this week').\n\nNOT for span or request latency recorded on traces (use search_traces).\n\n\nsearch_metrics(organizationSlug='my-org', query='p95 http.request.duration grouped by environment over the last 24 hours')\nsearch_metrics(organizationSlug='my-org', query='total tokens used per day this week')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_profiles", "description": "Search Sentry profiles: transaction and continuous profile results, profile IDs, and profiled transactions. Use to find profiles to inspect.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nUse get_profile or get_profile_details on a result for flamegraph and hotspot analysis.\n\n\nsearch_profiles(organizationSlug='my-org', query='recent profiles for the /checkout transaction')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_replays", "description": "Search Sentry session replays: rage clicks, dead clicks, visited pages, errors seen, and replay users.\n\n`query` is natural language (preferred) or replay search syntax; a configured agent translates it into a replay search.\n\nReturns replay lists only; count()/avg()/sum() are not supported. Use get_replay_details on a result for one replay.\n\n\nsearch_replays(organizationSlug='my-org', query='replays with rage clicks on checkout in the last day')\nsearch_replays(organizationSlug='my-org', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_traces", "description": "Search Sentry spans and traces: requests, API/HTTP calls, endpoints, DB queries, AI/LLM calls, and other operations. Use for latency, throughput, slowness, and performance questions.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nAlso use for spans whose trace contains a matching log, metric, or other span, e.g. 'slow checkout requests that also logged an error'.\n\nSupports aggregations ('p95 duration by span.op'), individual spans ('slowest API calls today'), and time series ('requests per minute last hour').\n\nNOT for exceptions/crashes (use search_errors), standalone log lines (use search_logs), or a single trace's span tree (use get_sentry_resource with the trace).\n\n\nsearch_traces(organizationSlug='my-org', query='slowest API calls in the last 24 hours')\nsearch_traces(organizationSlug='my-org', query='p95 duration of db spans grouped by span.op over 7 days')\nsearch_traces(organizationSlug='my-org', query='checkout requests that also have an error log')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "whoami", @@ -255,7 +255,7 @@ { "name": "analyze_issue_with_seer", "description": "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.\n\nUse this tool when:\n- The user explicitly asks for root cause analysis, Seer analysis, or help fixing/debugging an issue\n- You are unable to accurately determine the root cause from the issue details alone\n\nDo NOT call this tool as an automatic follow-up to get_sentry_resource.\n\nWhat this tool provides:\n- Root cause analysis with code-level explanations\n- Specific file locations and line numbers where errors occur\n- Concrete code fixes you can apply\n- Step-by-step implementation guidance\n\nThis tool automatically:\n1. Checks if analysis already exists (instant results)\n2. Starts new AI analysis if needed (~2-5 minutes)\n3. Returns complete fix recommendations\n\n\n### User: \"Run Seer on this issue\"\n\n```\nanalyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')\n```\n\n### User: \"Analyze this issue and suggest a fix\"\n\n```\nanalyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')\n```\n\n\n\n- Only use when the user explicitly requests analysis or you cannot determine the root cause from issue details alone\n- Seer Autofix does not support metric alert issues (issueCategory: metric); use get_issue_details and search_metrics or search_traces instead\n- If the user provides an issueUrl, extract it and use that parameter alone\n- The analysis includes actual code snippets and fixes, not just error descriptions\n- Results are cached - subsequent calls return instantly\n", - "requiredScopes": [] + "requiredScopes": ["event:write"] }, { "name": "find_organizations", @@ -300,7 +300,7 @@ { "name": "search_errors", "description": "Search Sentry error events: exceptions and crashes with stack traces. Use for error counts, statistics, trends, and individual error events.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('how many errors today', 'top error types'), individual events ('latest TypeErrors in checkout'), and time series ('errors per hour last 24h').\n\nNOT for log messages, including error/warning logs (use search_logs), requests or latency (use search_traces), or grouped issue lists (use search_issues).\n\n\nsearch_errors(organizationSlug='my-org', query='how many errors today')\nsearch_errors(organizationSlug='my-org', query='most common error types in production this week')\nsearch_errors(organizationSlug='my-org', query='errors per hour last 24h')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_issues", @@ -310,27 +310,27 @@ { "name": "search_logs", "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_metrics", "description": "Search Sentry metrics: counters, gauges, and distributions, as rows or aggregates. Use for metric values, percentiles, totals, and trends.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('p95 http.request.duration by environment'), individual metric rows, and time series ('total tokens per day this week').\n\nNOT for span or request latency recorded on traces (use search_traces).\n\n\nsearch_metrics(organizationSlug='my-org', query='p95 http.request.duration grouped by environment over the last 24 hours')\nsearch_metrics(organizationSlug='my-org', query='total tokens used per day this week')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_profiles", "description": "Search Sentry profiles: transaction and continuous profile results, profile IDs, and profiled transactions. Use to find profiles to inspect.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nUse get_profile or get_profile_details on a result for flamegraph and hotspot analysis.\n\n\nsearch_profiles(organizationSlug='my-org', query='recent profiles for the /checkout transaction')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_replays", "description": "Search Sentry session replays: rage clicks, dead clicks, visited pages, errors seen, and replay users.\n\n`query` is natural language (preferred) or replay search syntax; a configured agent translates it into a replay search.\n\nReturns replay lists only; count()/avg()/sum() are not supported. Use get_replay_details on a result for one replay.\n\n\nsearch_replays(organizationSlug='my-org', query='replays with rage clicks on checkout in the last day')\nsearch_replays(organizationSlug='my-org', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_traces", "description": "Search Sentry spans and traces: requests, API/HTTP calls, endpoints, DB queries, AI/LLM calls, and other operations. Use for latency, throughput, slowness, and performance questions.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nAlso use for spans whose trace contains a matching log, metric, or other span, e.g. 'slow checkout requests that also logged an error'.\n\nSupports aggregations ('p95 duration by span.op'), individual spans ('slowest API calls today'), and time series ('requests per minute last hour').\n\nNOT for exceptions/crashes (use search_errors), standalone log lines (use search_logs), or a single trace's span tree (use get_sentry_resource with the trace).\n\n\nsearch_traces(organizationSlug='my-org', query='slowest API calls in the last 24 hours')\nsearch_traces(organizationSlug='my-org', query='p95 duration of db spans grouped by span.op over 7 days')\nsearch_traces(organizationSlug='my-org', query='checkout requests that also have an error log')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "whoami", @@ -461,7 +461,7 @@ { "name": "search_errors", "description": "Search Sentry error events: exceptions and crashes with stack traces. Use for error counts, statistics, trends, and individual error events.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('how many errors today', 'top error types'), individual events ('latest TypeErrors in checkout'), and time series ('errors per hour last 24h').\n\nNOT for log messages, including error/warning logs (use search_logs), requests or latency (use search_traces), or grouped issue lists (use search_issues).\n\n\nsearch_errors(organizationSlug='my-org', query='how many errors today')\nsearch_errors(organizationSlug='my-org', query='most common error types in production this week')\nsearch_errors(organizationSlug='my-org', query='errors per hour last 24h')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_issue_events", @@ -476,27 +476,27 @@ { "name": "search_logs", "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n- Sentry search syntax for logs includes RE2 regex filters, never quoted: `message://^Timeout after \\d+ms//`.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_metrics", "description": "Search Sentry metrics: counters, gauges, and distributions, as rows or aggregates. Use for metric values, percentiles, totals, and trends.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('p95 http.request.duration by environment'), individual metric rows, and time series ('total tokens per day this week').\n\nNOT for span or request latency recorded on traces (use search_traces).\n\n\nsearch_metrics(organizationSlug='my-org', query='p95 http.request.duration grouped by environment over the last 24 hours')\nsearch_metrics(organizationSlug='my-org', query='total tokens used per day this week')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_profiles", "description": "Search Sentry profiles: transaction and continuous profile results, profile IDs, and profiled transactions. Use to find profiles to inspect.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nUse get_profile or get_profile_details on a result for flamegraph and hotspot analysis.\n\n\nsearch_profiles(organizationSlug='my-org', query='recent profiles for the /checkout transaction')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_replays", "description": "Search Sentry session replays: rage clicks, dead clicks, visited pages, errors seen, and replay users.\n\n`query` is natural language (preferred) or replay search syntax; a configured agent translates it into a replay search.\n\nReturns replay lists only; count()/avg()/sum() are not supported. Use get_replay_details on a result for one replay.\n\n\nsearch_replays(organizationSlug='my-org', query='replays with rage clicks on checkout in the last day')\nsearch_replays(organizationSlug='my-org', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "search_traces", "description": "Search Sentry spans and traces: requests, API/HTTP calls, endpoints, DB queries, AI/LLM calls, and other operations. Use for latency, throughput, slowness, and performance questions.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nAlso use for spans whose trace contains a matching log, metric, or other span, e.g. 'slow checkout requests that also logged an error'.\n\nSupports aggregations ('p95 duration by span.op'), individual spans ('slowest API calls today'), and time series ('requests per minute last hour').\n\nNOT for exceptions/crashes (use search_errors), standalone log lines (use search_logs), or a single trace's span tree (use get_sentry_resource with the trace).\n\n\nsearch_traces(organizationSlug='my-org', query='slowest API calls in the last 24 hours')\nsearch_traces(organizationSlug='my-org', query='p95 duration of db spans grouped by span.op over 7 days')\nsearch_traces(organizationSlug='my-org', query='checkout requests that also have an error log')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", - "requiredScopes": ["event:read"] + "requiredScopes": ["org:read", "project:read"] }, { "name": "unlink_issue", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 7b621db13..e8cd7a937 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -146,7 +146,7 @@ } } }, - "requiredScopes": [], + "requiredScopes": ["event:write"], "skills": ["seer"], "surface": "direct" }, @@ -6324,7 +6324,7 @@ }, "required": ["organizationSlug", "traceId", "spanId"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read"], "skills": ["inspect"], "surface": "catalog" }, @@ -6363,7 +6363,7 @@ }, "required": ["organizationSlug", "traceId"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read"], "skills": ["inspect"], "surface": "catalog" }, @@ -7563,7 +7563,7 @@ }, "required": ["organizationSlug"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read", "project:read"], "skills": ["inspect", "triage", "seer"], "surface": "direct" }, @@ -7672,7 +7672,7 @@ }, "required": ["organizationSlug"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read", "project:read"], "skills": ["inspect", "triage", "seer"], "surface": "catalog" }, @@ -7914,7 +7914,7 @@ }, "required": ["organizationSlug"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read", "project:read"], "skills": ["inspect", "triage", "seer"], "surface": "direct" }, @@ -8001,7 +8001,7 @@ }, "required": ["organizationSlug"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read", "project:read"], "skills": ["inspect", "triage", "seer"], "surface": "direct" }, @@ -8088,7 +8088,7 @@ }, "required": ["organizationSlug"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read", "project:read"], "skills": ["inspect", "triage", "seer"], "surface": "direct" }, @@ -8178,7 +8178,7 @@ }, "required": ["organizationSlug"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read", "project:read"], "skills": ["inspect", "triage", "seer"], "surface": "direct" }, @@ -8349,7 +8349,7 @@ }, "required": ["organizationSlug"] }, - "requiredScopes": ["event:read"], + "requiredScopes": ["org:read", "project:read"], "skills": ["inspect", "triage", "seer"], "surface": "direct" }, diff --git a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts index 20da93674..d341460a0 100644 --- a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts +++ b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts @@ -33,7 +33,9 @@ import type { ServerContext } from "../../types"; export default defineTool({ name: "analyze_issue_with_seer", skills: ["seer"], // Only available in seer skill - requiredScopes: [], // No Sentry API scopes required - authorization via 'seer' skill + // Starting an autofix run is a POST to /issues/{id}/autofix/, which Sentry + // gates on event:write (GroupAiPermission). + requiredScopes: ["event:write"], description: [ "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.", "", diff --git a/packages/mcp-core/src/tools/catalog/get-span-details.ts b/packages/mcp-core/src/tools/catalog/get-span-details.ts index 1ae46d578..9a841d4dd 100644 --- a/packages/mcp-core/src/tools/catalog/get-span-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-span-details.ts @@ -11,7 +11,7 @@ import getTraceDetails from "./get-trace-details"; export default defineTool({ name: "get_span_details", skills: ["inspect"], - requiredScopes: ["event:read"], + requiredScopes: ["org:read"], requiredCapabilities: ["traces"], description: [ "Get detailed information about a specific span within a Sentry trace.", diff --git a/packages/mcp-core/src/tools/catalog/get-trace-details.ts b/packages/mcp-core/src/tools/catalog/get-trace-details.ts index ba2758b10..231cdbcb4 100644 --- a/packages/mcp-core/src/tools/catalog/get-trace-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-trace-details.ts @@ -61,7 +61,7 @@ interface SpanExpansionCandidate { export default defineTool({ name: "get_trace_details", skills: ["inspect"], // Only available in inspect skill - requiredScopes: ["event:read"], + requiredScopes: ["org:read"], requiredCapabilities: ["traces"], description: [ "Get detailed information about a specific Sentry trace by ID.", diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 07c4e729e..e3508c6c2 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -557,7 +557,7 @@ export function searchToolBase(toolName: string): { } { return { skills: ["inspect", "triage", "seer"], - requiredScopes: ["event:read"], + requiredScopes: ["org:read", "project:read"], annotations: SEARCH_EVENTS_ANNOTATIONS, onError(error, params, context) { logSearchEventsError(toolName, error, params, context);