diff --git a/.gitignore b/.gitignore index d7bbb7e9e..520ba095c 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ coverage # Generated files packages/mcp-server/src/toolDefinitions.json +packages/mcp-server/src/skillDefinitions.json diff --git a/docs/permissions-and-scopes.md b/docs/permissions-and-scopes.md deleted file mode 100644 index 7b9604012..000000000 --- a/docs/permissions-and-scopes.md +++ /dev/null @@ -1,120 +0,0 @@ -# Permissions and Scopes - -OAuth-style scope system for controlling access to Sentry MCP tools. - -## Default Permissions - -**By default, all users receive read-only access.** This includes: -- `org:read`, `project:read`, `team:read`, `event:read` - -Additional permissions must be explicitly granted through the OAuth flow or CLI arguments. - -## Permission Levels - -When authenticating via OAuth, users can select additional permissions: - -| Level | Scopes | Tools Enabled | -|-------|--------|--------------| -| **Read-Only** (default) | `org:read`, `project:read`, `team:read`, `event:read` | Search, view issues/traces, documentation | -| **+ Issue Triage** | Adds `event:write` | All above + resolve/assign issues, AI analysis | -| **+ Project Management** | Adds `project:write`, `team:write` | All above + create/modify projects/teams/DSNs | - -### CLI Usage - -```bash -# Default: read-only access -npx @sentry/mcp-server --access-token=TOKEN - -# Override defaults with specific scopes only -npx @sentry/mcp-server --access-token=TOKEN --scopes=org:read,event:read - -# Add write permissions to default read-only scopes -npx @sentry/mcp-server --access-token=TOKEN --add-scopes=event:write,project:write - -# Via environment variables -export MCP_SCOPES=org:read,project:write # Overrides defaults -export MCP_ADD_SCOPES=event:write # Adds to defaults -npx @sentry/mcp-server --access-token=TOKEN -``` - -Precedence and validation: -- Flags override environment variables. If `--scopes` is provided, `MCP_SCOPES` is ignored. If `--add-scopes` is provided, `MCP_ADD_SCOPES` is ignored. -- Flags and env vars are strict: any invalid scope token causes an error listing allowed scopes. - -**Note:** `--scopes` completely replaces the default scopes, while `--add-scopes` adds to them. - -## Scope Hierarchy - -Higher scopes include lower ones: - -``` -admin → write → read -``` - -Examples: -- `team:write` includes `team:read` -- `event:admin` includes `event:write` and `event:read` - -## Available Scopes - -| Resource | Read | Write | Admin | -|----------|------|-------|-------| -| **Organization** | `org:read` | `org:write` | `org:admin` | -| **Project** | `project:read` | `project:write` | `project:admin` | -| **Team** | `team:read` | `team:write` | `team:admin` | -| **Member** | `member:read` | `member:write` | `member:admin` | -| **Event/Issue** | `event:read` | `event:write` | `event:admin` | -| **Special** | `project:releases` | - | - | - -## Tool Requirements - -### Always Available (No Scopes) -- `whoami` - User identification -- `search_docs` - Documentation search -- `get_doc` - Documentation retrieval - -### Read Operations -- `find_organizations` - `org:read` -- `find_projects` - `project:read` -- `find_teams` - `team:read` -- `find_releases` - `project:read` -- `find_dsns` - `project:read` -- `get_issue_details` - `event:read` -- `get_event_attachment` - `event:read` -- `get_trace_details` - `event:read` -- `search_events` - `event:read` -- `search_issues` - `event:read` -- `analyze_issue_with_seer` - `event:read` - -### Write Operations -- `update_issue` - `event:write` -- `create_project` - `project:write`, `team:read` -- `update_project` - `project:write` -- `create_team` - `team:write` -- `create_dsn` - `project:write` - -## How It Works - -1. **Sentry Authentication**: MCP requests all necessary scopes from Sentry -2. **Permission Selection**: User chooses permission level in approval dialog -3. **Tool Filtering**: MCP filters available tools based on granted scopes -4. **Runtime Validation**: Scopes checked when tools are invoked - -## Notes - -- Default behavior grants read-only access if no scopes specified -- Embedded agent tools don't require scope binding -- Documentation tools always available regardless of scopes - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| Tool not in list | Check required scopes are granted | -| "Tool not allowed" error | Re-authenticate with higher permission level | -| Invalid scope | Use lowercase with colon separator (e.g., `event:write`) | - -## References - -- Adding Tools: @docs/adding-tools.mdc — Add tools with scope requirements -- Testing: @docs/testing.mdc — Test with different scope configurations diff --git a/docs/testing-remote.md b/docs/testing-remote.md index 5a3a339fb..124605950 100644 --- a/docs/testing-remote.md +++ b/docs/testing-remote.md @@ -232,7 +232,7 @@ The web UI provides a chat interface for testing the MCP server. **1. Authentication:** - Click "Connect to Sentry" - Authorize the application -- Grant permissions (select scopes) +- Grant permissions (select skills) **2. Basic Queries:** - "Who am I?" - Test authentication @@ -292,14 +292,14 @@ Opens at `http://localhost:6274` - Redirected back to Inspector **3. Test Tools:** -- Click "List Tools" - Verify 19-20 tools appear +- Click "List Tools" - Verify tools appear - Test individual tools with parameters - View responses and errors ### Inspector Testing Patterns **Basic verification:** -1. List tools → Verify count and names +1. List tools → Verify expected tools available 2. Call `whoami` → Verify authentication 3. Call `find_organizations` → Verify data access @@ -348,19 +348,19 @@ ls -la ~/.sentry-mcp-tokens.json pnpm -w run cli "list organizations" ``` -### 2. Test Scope Permissions +### 2. Test Skills Permissions -**In Sentry OAuth approval screen, test:** -- Minimal scopes (org:read only) -- Standard scopes (org:read, project:read, event:read) -- Write scopes (add event:write, project:write) +**In OAuth approval screen, test:** +- Minimal skills (inspect, docs only) +- Default skills (inspect, seer, docs) +- All skills (inspect, seer, docs, triage, project-management) -**Verify tools filtered by scopes:** +**Verify tools filtered by skills:** ```bash -# Read-only: should have ~15 tools +# With inspect, docs only: no write tools pnpm -w run cli "list tools" | grep "create_" -# With write: should have 19-20 tools +# With all skills: includes write tools # Should see: create_project, create_team, update_issue, etc. ``` @@ -470,21 +470,21 @@ cat packages/mcp-cloudflare/.env | grep SENTRY_CLIENT ### "Permission denied" errors -**Cause:** Insufficient scopes granted during OAuth. +**Cause:** Insufficient skills granted during OAuth. **Solution:** ```bash -# Force re-authorization with more scopes +# Force re-authorization with more skills rm ~/.sentry-mcp-tokens.json pnpm -w run cli "who am I?" -# In OAuth approval screen, select all needed permissions +# In OAuth approval screen, select all needed skills ``` ### "Tool not found" errors **Causes:** -1. Tool filtered by scopes +1. Tool filtered by skills 2. Build issue 3. Server version mismatch @@ -493,8 +493,8 @@ pnpm -w run cli "who am I?" # Check tool list pnpm -w run cli "list tools" | jq '.tools[] | .name' -# Verify scopes include required permissions -# Example: create_project requires project:write scope +# Verify skills include required permissions +# Example: create_project requires project-management skill # Rebuild and restart pnpm -w run build && pnpm dev diff --git a/docs/testing-stdio.md b/docs/testing-stdio.md index 36ddf8a91..ff060e8a0 100644 --- a/docs/testing-stdio.md +++ b/docs/testing-stdio.md @@ -182,7 +182,7 @@ This opens the MCP Inspector at `http://localhost:6274` ### 3. Test Tools Interactively **Basic workflow:** -1. **List Tools** - Verify all expected tools appear (should see 19-20 tools) +1. **List Tools** - Verify expected tools appear 2. **Call a tool** - Start with `whoami` (no parameters required) 3. **Test with parameters** - Try `find_organizations()` 4. **Test complex operations** - Try `search_events(naturalLanguageQuery="errors in the last hour")` @@ -379,9 +379,8 @@ Add to `.vscode/settings.json`: # Host configuration --host=sentry.example.com # Self-hosted Sentry (hostname only) -# Scope management ---scopes=org:read,event:read # Override default scopes (replaces all) ---add-scopes=event:write # Add to default scopes (keeps defaults) +# Skills management +--skills=inspect,docs,triage # Limit to specific skills (default: all available) # AI features (optional) --openai-base-url=URL # Custom OpenAI endpoint @@ -407,9 +406,8 @@ SENTRY_ACCESS_TOKEN=your-token # Host (self-hosted only) SENTRY_HOST=sentry.example.com -# Scopes -MCP_SCOPES=org:read,event:read # Override (replaces defaults) -MCP_ADD_SCOPES=event:write # Add (keeps defaults) +# Skills +MCP_SKILLS=inspect,docs,triage # Limit to specific skills # AI features OPENAI_API_KEY=your-key # For search_events/search_issues @@ -449,17 +447,17 @@ node dist/index.js \ --host=sentry.local.dev ``` -### 3. Test Scope Permissions +### 3. Test Skills ```bash -# Test read-only (default) +# Test with all skills (default) pnpm start --access-token=TOKEN -# Test with write permissions -pnpm start --access-token=TOKEN --add-scopes=event:write,project:write +# Test with specific skills only +pnpm start --access-token=TOKEN --skills=inspect,docs -# Test minimal permissions -pnpm start --access-token=TOKEN --scopes=org:read,project:read +# Test read-only skills +pnpm start --access-token=TOKEN --skills=inspect,seer,docs ``` ### 4. Test AI-Powered Tools diff --git a/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts b/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts index 7ee23c4ef..63065da6b 100644 --- a/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts +++ b/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts @@ -4,6 +4,9 @@ import type { } from "@cloudflare/workers-oauth-provider"; import { logError, logIssue, logWarn } from "@sentry/mcp-server/telem/logging"; import { sanitizeHtml } from "./html-utils"; +import skillDefinitions, { + type SkillDefinition, +} from "@sentry/mcp-server/skillDefinitions"; const COOKIE_NAME = "mcp-approved-clients"; const ONE_YEAR_IN_SECONDS = 31536000; @@ -242,12 +245,34 @@ function decodeState(encoded: string): T { * @param options - Configuration for the approval dialog * @returns A Response containing the HTML approval dialog */ -export function renderApprovalDialog( +export async function renderApprovalDialog( request: Request, options: ApprovalDialogOptions, -): Response { +): Promise { const { client, server, state } = options; + // Use static skill definitions bundled at build time + const skills: SkillDefinition[] = skillDefinitions as SkillDefinition[]; + + // Generate HTML for all skills (checked if defaultEnabled) + const skillsHtml = skills + .map( + (skill) => ` + + `, + ) + .join(""); + // Encode state for form submission const encodedState = encodeState(state); @@ -289,466 +314,512 @@ export function renderApprovalDialog(
-
+
- -

${serverName}

+ +

${serverName}

-
- -
- -

${clientName || "A new MCP Client"} is requesting access

- -
-
-
Name:
-
- ${clientName} -
-
- - ${ - clientUri - ? ` -
-
Website:
- -
- ` - : "" - } - - ${ - policyUri - ? ` -
-
Privacy Policy:
- -
- ` - : "" - } - - ${ - tosUri - ? ` -
-
Terms of Service:
- -
- ` - : "" - } - - ${ - redirectUris.length > 0 - ? ` -
-
Redirect URIs:
-
- ${redirectUris.map((uri) => `
${uri}
`).join("")} -
-
- ` - : "" - } - - ${ - contacts - ? ` -
-
Contact:
-
${contacts}
-
- ` - : "" - } -
- -

This MCP Client is requesting authorization to ${serverName}. If you approve, you will be redirected to complete authentication.

+ + +
-
+

${clientName || "A new MCP Client"} is requesting access

+ + - -
-

Permissions

- - -
-
- ✓ -
- Read-only access to your Sentry data -
View organizations, projects, teams, issues, and releases
+ +
+ +
+

Skills

+
+
+ ${skillsHtml}
-
+
- - -
-

Optional additional access:

- - - - - - - -
+
@@ -769,8 +840,8 @@ export interface ParsedApprovalResult { state: any; /** Headers to set on the redirect response, including the Set-Cookie header. */ headers: Record; - /** Selected permission levels */ - permissions: string[]; + /** Selected skills */ + skills: string[]; } /** @@ -792,7 +863,7 @@ export async function parseRedirectApproval( let state: any; let clientId: string | undefined; - let permissions: string[]; + let skills: string[]; try { const formData = await request.formData(); @@ -809,10 +880,10 @@ export async function parseRedirectApproval( throw new Error("Could not extract clientId from state object."); } - // Extract permission selections from checkboxes - collect all 'permission' field values - permissions = formData - .getAll("permission") - .filter((p): p is string => typeof p === "string"); + // Extract skill selections from checkboxes - collect all 'skill' field values + skills = formData + .getAll("skill") + .filter((s): s is string => typeof s === "string"); } catch (error) { logError(error, { loggerScope: ["cloudflare", "approval-dialog"], @@ -846,7 +917,7 @@ export async function parseRedirectApproval( "Set-Cookie": `${COOKIE_NAME}=${newCookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${ONE_YEAR_IN_SECONDS}`, }; - return { state, headers, permissions }; + return { state, headers, skills }; } // sanitizeHtml function is now imported from "./html-utils" diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts index feba96fec..7a9844cbe 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts @@ -16,7 +16,8 @@ import { parseScopes, type Scope, } from "@sentry/mcp-server/permissions"; -import { logWarn } from "@sentry/mcp-server/telem/logging"; +import { parseSkills, type Skill } from "@sentry/mcp-server/skills"; +import { logIssue, logWarn } from "@sentry/mcp-server/telem/logging"; import type { ServerContext } from "@sentry/mcp-server/types"; import type { Env } from "../types"; import { verifyConstraintsAccess } from "./constraint-utils"; @@ -85,7 +86,7 @@ const mcpHandler: ExportedHandler = { }); } - // Parse and expand granted scopes + // Parse and expand granted scopes (LEGACY - for backward compatibility) let expandedScopes: Set | undefined; if (oauthCtx.props.grantedScopes) { const { valid, invalid } = parseScopes( @@ -102,12 +103,62 @@ const mcpHandler: ExportedHandler = { expandedScopes = expandScopes(new Set(valid)); } + // Parse and validate granted skills (NEW - primary authorization method) + let grantedSkills: Set | undefined; + if (oauthCtx.props.grantedSkills) { + const { valid, invalid } = parseSkills( + oauthCtx.props.grantedSkills as string[], + ); + if (invalid.length > 0) { + logWarn("Ignoring invalid skills from OAuth provider", { + loggerScope: ["cloudflare", "mcp-handler"], + extra: { + invalidSkills: invalid, + }, + }); + } + grantedSkills = new Set(valid); + + // Validate that at least one valid skill was granted + if (grantedSkills.size === 0) { + return new Response( + "Authorization failed: No valid skills were granted. Please re-authorize and select at least one permission.", + { status: 400 }, + ); + } + } + + // Validate that at least one authorization system is active + // This should never happen in practice - indicates a bug in OAuth flow + if (!grantedSkills && !expandedScopes) { + logIssue( + new Error( + "No authorization grants found - server would expose no tools", + ), + { + loggerScope: ["cloudflare", "mcp-handler"], + extra: { + clientId: oauthCtx.props.clientId, + hasGrantedSkills: !!oauthCtx.props.grantedSkills, + hasGrantedScopes: !!oauthCtx.props.grantedScopes, + }, + }, + ); + return new Response( + "Authorization failed: No valid permissions were granted. Please re-authorize and select at least one permission.", + { status: 400 }, + ); + } + // Build complete ServerContext from OAuth props + verified constraints const serverContext: ServerContext = { userId: oauthCtx.props.id as string | undefined, clientId: oauthCtx.props.clientId as string, accessToken: oauthCtx.props.accessToken as string, + // Scopes derived from skills - for backward compatibility with old MCP clients + // that don't support grantedSkills and only understand grantedScopes grantedScopes: expandedScopes, + grantedSkills, // Primary authorization method constraints: verification.constraints, sentryHost, mcpUrl: env.MCP_URL, diff --git a/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts b/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts index e9dfe7888..42b72308a 100644 --- a/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts +++ b/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts @@ -69,7 +69,7 @@ describe("oauth authorize routes", () => { tokenEndpointAuthMethod: "client_secret_basic", }); }); - it("should encode permissions in the redirect state", async () => { + it("should encode skills in the redirect state", async () => { const oauthReqInfo = { clientId: "test-client", redirectUri: "https://example.com/callback", @@ -78,8 +78,8 @@ describe("oauth authorize routes", () => { }; const formData = new FormData(); formData.append("state", btoa(JSON.stringify({ oauthReqInfo }))); - formData.append("permission", "issue_triage"); - formData.append("permission", "project_management"); + formData.append("skill", "triage"); + formData.append("skill", "project-management"); const request = new Request("http://localhost/oauth/authorize", { method: "POST", body: formData, @@ -97,9 +97,9 @@ describe("oauth authorize routes", () => { stateParam!, testEnv.COOKIE_SECRET as string, ); - expect((decodedState.req as any).permissions).toEqual([ - "issue_triage", - "project_management", + expect((decodedState.req as any).skills).toEqual([ + "triage", + "project-management", ]); expect((decodedState.req as any).clientId).toBe("test-client"); expect((decodedState.req as any).redirectUri).toBe( @@ -108,7 +108,7 @@ describe("oauth authorize routes", () => { expect((decodedState.req as any).scope).toEqual(["read", "write"]); }); - it("should handle no permissions selected (read-only default)", async () => { + it("should handle no skills selected", async () => { const oauthReqInfo = { clientId: "test-client", redirectUri: "https://example.com/callback", @@ -131,10 +131,10 @@ describe("oauth authorize routes", () => { stateParam!, testEnv.COOKIE_SECRET as string, ); - expect((decodedState.req as any).permissions).toEqual([]); + expect((decodedState.req as any).skills).toEqual([]); }); - it("should handle only issue triage permission", async () => { + it("should handle only triage skill", async () => { const oauthReqInfo = { clientId: "test-client", redirectUri: "https://example.com/callback", @@ -143,7 +143,7 @@ describe("oauth authorize routes", () => { }; const formData = new FormData(); formData.append("state", btoa(JSON.stringify({ oauthReqInfo }))); - formData.append("permission", "issue_triage"); + formData.append("skill", "triage"); const request = new Request("http://localhost/oauth/authorize", { method: "POST", body: formData, @@ -157,7 +157,7 @@ describe("oauth authorize routes", () => { stateParam!, testEnv.COOKIE_SECRET as string, ); - expect((decodedState.req as any).permissions).toEqual(["issue_triage"]); + expect((decodedState.req as any).skills).toEqual(["triage"]); }); it("should include Set-Cookie header for approval", async () => { diff --git a/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts b/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts index 370df8921..ec764ffeb 100644 --- a/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts +++ b/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts @@ -12,16 +12,16 @@ import { signState, type OAuthState } from "../state"; import { logWarn } from "@sentry/mcp-server/telem/logging"; /** - * Extended AuthRequest that includes permissions + * Extended AuthRequest that includes skills */ -interface AuthRequestWithPermissions extends AuthRequest { - permissions?: unknown; +interface AuthRequestWithSkills extends AuthRequest { + skills?: unknown; } async function redirectToUpstream( env: Env, request: Request, - oauthReqInfo: AuthRequest | AuthRequestWithPermissions, + oauthReqInfo: AuthRequest | AuthRequestWithSkills, headers: Record = {}, stateOverride?: string, ) { @@ -90,7 +90,7 @@ export default new Hono<{ Bindings: Env }>() // return redirectToUpstream(c.env, c.req.raw, oauthReqInfo); // } - return renderApprovalDialog(c.req.raw, { + return await renderApprovalDialog(c.req.raw, { client: await c.env.OAUTH_PROVIDER.lookupClient(clientId), server: { name: "Sentry MCP", @@ -117,33 +117,33 @@ export default new Hono<{ Bindings: Env }>() return c.text("Invalid request", 400); } - const { state, headers, permissions } = result; + const { state, headers, skills } = result; if (!state.oauthReqInfo) { return c.text("Invalid request", 400); } - // Store the selected permissions in the OAuth request info + // Store the selected skills in the OAuth request info // This will be passed through to the callback via the state parameter - const oauthReqWithPermissions = { + const oauthReqWithSkills = { ...state.oauthReqInfo, - permissions, + skills, }; // Validate redirectUri is registered for this client before proceeding try { const client = await c.env.OAUTH_PROVIDER.lookupClient( - oauthReqWithPermissions.clientId, + oauthReqWithSkills.clientId, ); const uriIsAllowed = Array.isArray(client?.redirectUris) && - client.redirectUris.includes(oauthReqWithPermissions.redirectUri); + client.redirectUris.includes(oauthReqWithSkills.redirectUri); if (!uriIsAllowed) { logWarn("Redirect URI not registered for client", { loggerScope: ["cloudflare", "oauth", "authorize"], extra: { - clientId: oauthReqWithPermissions.clientId, - redirectUri: oauthReqWithPermissions.redirectUri, + clientId: oauthReqWithSkills.clientId, + redirectUri: oauthReqWithSkills.redirectUri, }, }); return c.text("Invalid redirect URI", 400); @@ -159,7 +159,7 @@ export default new Hono<{ Bindings: Env }>() // Build signed state for redirect to Sentry (10 minute validity) const now = Date.now(); const payload: OAuthState = { - req: oauthReqWithPermissions as unknown as Record, + req: oauthReqWithSkills as unknown as Record, iat: now, exp: now + 10 * 60 * 1000, }; @@ -168,7 +168,7 @@ export default new Hono<{ Bindings: Env }>() return redirectToUpstream( c.env, c.req.raw, - oauthReqWithPermissions, + oauthReqWithSkills, headers, signedState, ); diff --git a/packages/mcp-cloudflare/src/server/oauth/routes/callback.ts b/packages/mcp-cloudflare/src/server/oauth/routes/callback.ts index cb012de13..30ee55afc 100644 --- a/packages/mcp-cloudflare/src/server/oauth/routes/callback.ts +++ b/packages/mcp-cloudflare/src/server/oauth/routes/callback.ts @@ -2,61 +2,17 @@ import { Hono } from "hono"; import type { AuthRequest } from "@cloudflare/workers-oauth-provider"; import { clientIdAlreadyApproved } from "../../lib/approval-dialog"; import type { Env, WorkerProps } from "../../types"; -import type { Scope } from "@sentry/mcp-server/permissions"; -import { DEFAULT_SCOPES } from "@sentry/mcp-server/constants"; import { SENTRY_TOKEN_URL } from "../constants"; import { exchangeCodeForAccessToken } from "../helpers"; import { verifyAndParseState, type OAuthState } from "../state"; import { logWarn } from "@sentry/mcp-server/telem/logging"; +import { parseSkills, getScopesForSkills } from "@sentry/mcp-server/skills"; /** - * Extended AuthRequest that includes permissions + * Extended AuthRequest that includes skills */ -interface AuthRequestWithPermissions extends AuthRequest { - permissions?: unknown; -} - -/** - * Convert selected permissions to granted scopes - * Permissions are additive: - * - Base (always included): org:read, project:read, team:read, event:read - * - Seer adds: seer (virtual scope) - * - Docs adds: docs (virtual scope) - * - Issue Triage adds: event:write - * - Project Management adds: project:write, team:write - * @param permissions Array of permission strings - */ -function getScopesFromPermissions(permissions?: unknown): Set { - // Start with base read-only scopes (always granted via DEFAULT_SCOPES) - const scopes = new Set(DEFAULT_SCOPES); - - // Validate permissions is an array of strings - if (!Array.isArray(permissions) || permissions.length === 0) { - return scopes; - } - const perms = (permissions as unknown[]).filter( - (p): p is string => typeof p === "string", - ); - - // Add scopes based on selected permissions - if (perms.includes("seer")) { - scopes.add("seer"); - } - - if (perms.includes("docs")) { - scopes.add("docs"); - } - - if (perms.includes("issue_triage")) { - scopes.add("event:write"); - } - - if (perms.includes("project_management")) { - scopes.add("project:write"); - scopes.add("team:write"); - } - - return scopes; +interface AuthRequestWithSkills extends AuthRequest { + skills?: unknown; // Skill-based authorization system } /** @@ -83,7 +39,7 @@ export default new Hono<{ Bindings: Env }>().get("/", async (c) => { } // Reconstruct oauth request info exactly as provided by downstream client - const oauthReqInfo = parsedState.req as unknown as AuthRequestWithPermissions; + const oauthReqInfo = parsedState.req as unknown as AuthRequestWithSkills; if (!oauthReqInfo.clientId) { logWarn("Missing clientId in OAuth state", { @@ -164,8 +120,42 @@ export default new Hono<{ Bindings: Env }>().get("/", async (c) => { }); if (errResponse) return errResponse; - // Get scopes based on selected permissions - const grantedScopes = getScopesFromPermissions(oauthReqInfo.permissions); + // Parse and validate granted skills first + const { valid: validSkills, invalid: invalidSkills } = parseSkills( + oauthReqInfo.skills, + ); + + // Log warning for any invalid skill names + if (invalidSkills.length > 0) { + logWarn("OAuth callback received invalid skill names", { + loggerScope: ["cloudflare", "oauth", "callback"], + extra: { + clientId: oauthReqInfo.clientId, + invalidSkills, + }, + }); + } + + // Validate that at least one valid skill is granted + if (validSkills.size === 0) { + logWarn("OAuth authorization rejected: No valid skills selected", { + loggerScope: ["cloudflare", "oauth", "callback"], + extra: { + clientId: oauthReqInfo.clientId, + receivedSkills: oauthReqInfo.skills, + }, + }); + return c.text( + "Authorization failed: You must select at least one valid permission to continue.", + 400, + ); + } + + // Calculate Sentry API scopes from validated skills + const grantedScopes = await getScopesForSkills(validSkills); + + // Convert valid skills Set to array for OAuth props + const grantedSkills = Array.from(validSkills); // Return back to the MCP client a new token const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ @@ -188,7 +178,10 @@ export default new Hono<{ Bindings: Env }>().get("/", async (c) => { accessTokenExpiresAt: Date.now() + payload.expires_in * 1000, clientId: oauthReqInfo.clientId, scope: oauthReqInfo.scope.join(" "), + // Scopes derived from skills - for backward compatibility with old MCP clients + // that don't support grantedSkills and only understand grantedScopes grantedScopes: Array.from(grantedScopes), + grantedSkills, // Primary authorization method // Note: constraints are NOT included here - they're extracted per-request from URL // Note: sentryHost and mcpUrl come from env, not OAuth props diff --git a/packages/mcp-cloudflare/src/server/types.ts b/packages/mcp-cloudflare/src/server/types.ts index 94b53139a..5cc83bd8c 100644 --- a/packages/mcp-cloudflare/src/server/types.ts +++ b/packages/mcp-cloudflare/src/server/types.ts @@ -20,7 +20,10 @@ export type WorkerProps = { accessTokenExpiresAt?: number; // Timestamp when the upstream access token expires clientId: string; scope: string; - grantedScopes?: string[]; // Array of scope strings + // Scopes derived from skills - for backward compatibility with old MCP clients + // that don't support grantedSkills and only understand grantedScopes + grantedScopes?: string[]; + grantedSkills?: string[]; // Array of skill strings (primary authorization method) // Note: constraints are NOT included - they're extracted per-request from URL // Note: sentryHost and mcpUrl come from env, not OAuth props diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index e8922664e..c6a628d63 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -9,36 +9,35 @@ This package is primarily for running the `stdio` MCP server. If you do not know **Note:** Some tools require additional configuration: - **AI-powered search tools** (`search_events` and `search_issues`): These tools use OpenAI to translate natural language queries into Sentry's query syntax. They require an `OPENAI_API_KEY` environment variable. Without this key, these specific tools will be unavailable, but all other tools will function normally. -## Permissions and Scopes +## Authorization -By default, the MCP server runs with **read-only access** to your Sentry data: -- `org:read`, `project:read`, `team:read`, `event:read` +The MCP server uses a **skills-based authorization system** that maps user-friendly capabilities to technical API permissions. -### Customizing Permissions +### Available Skills -You can customize permissions using two different approaches: +By default (no `--skills` flag), the MCP server grants **ALL skills** for non-interactive convenience: -- **`--scopes`**: **Override** the default scopes completely (replaces all defaults) -- **`--add-scopes`**: **Add** scopes to the default read-only set (keeps defaults and adds more) +- **`inspect`** (default) - View organizations, projects, teams, issues, traces, and search for errors +- **`docs`** (default) - Search and read Sentry SDK documentation +- **`seer`** (default) - Use Seer to analyze issues and generate fix recommendations +- **`triage`** - Resolve, assign, and update issues +- **`project-management`** - Create and modify projects, teams, and DSNs -To utilize the `stdio` transport, you'll need to create an User Auth Token in Sentry. The token must have at least read access, but you can grant additional permissions as needed. +### Customizing Skills -### Examples +You can limit which skills are granted using the `--skills` flag: ```shell -# Default read-only access (SaaS) +# Default: ALL skills (inspect, docs, seer, triage, project-management) npx @sentry/mcp-server@latest --access-token=sentry-user-token -# Override with specific scopes only (removes defaults) -npx @sentry/mcp-server@latest --access-token=TOKEN --scopes=org:read,event:read - -# Add write permissions to defaults (keeps all defaults) -npx @sentry/mcp-server@latest --access-token=TOKEN --add-scopes=event:write,project:write +# Limit to specific skills only +npx @sentry/mcp-server@latest --access-token=TOKEN --skills=inspect,docs -# Point at a self-hosted deployment -npx @sentry/mcp-server@latest --access-token=sentry-user-token --host=sentry.example.com +# Self-hosted Sentry +npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com -# Override the OpenAI API endpoint for embedded agents (stdio only) +# Override OpenAI endpoint for AI-powered tools (stdio only) npx @sentry/mcp-server@latest --access-token=TOKEN --openai-base-url=https://proxy.example.com/v1 ``` @@ -49,27 +48,51 @@ You can also use environment variables: ```shell SENTRY_ACCESS_TOKEN=your-token # Optional overrides. Leave unset to use the default SaaS host -SENTRY_HOST=sentry.example.com -MCP_SCOPES=org:read,event:read # Override default scopes (replaces defaults) -MCP_ADD_SCOPES=event:write # Add to default scopes (keeps defaults) +SENTRY_HOST=sentry.example.com # Self-hosted Sentry hostname +MCP_SKILLS=inspect,docs,triage # Limit to specific skills +MCP_SCOPES=org:read,event:read # Override default scopes (replaces defaults) - DEPRECATED, use MCP_SKILLS +MCP_ADD_SCOPES=event:write # Add to default scopes (keeps defaults) - DEPRECATED, use MCP_SKILLS # OpenAI configuration for AI-powered search tools -OPENAI_API_KEY=your-openai-key # Required for AI-powered search tools (search_events, search_issues) -OPENAI_MODEL=gpt-5 # OpenAI model to use (default: "gpt-5") -OPENAI_REASONING_EFFORT=low # Reasoning effort for o1 models: "low", "medium", "high", or "" to disable (default: "low") +OPENAI_API_KEY=your-openai-key # Required for AI-powered search tools (search_events, search_issues) +OPENAI_MODEL=gpt-5 # OpenAI model to use (default: "gpt-5") +OPENAI_REASONING_EFFORT=low # Reasoning effort for o1 models: "low", "medium", "high", or "" to disable (default: "low") # No environment variable exists for the OpenAI base URL override; use --openai-base-url instead. # This restriction prevents unexpected environment overrides that could silently reroute requests to a # malicious proxy capable of harvesting the OpenAI API key provided at runtime. ``` -If `SENTRY_HOST` is not provided, the CLI automatically targets the Sentry SaaS -endpoint. +If `SENTRY_HOST` is not provided, the CLI automatically targets the Sentry SaaS endpoint. Configure this variable only when you operate a self-hosted Sentry deployment. + +**Note:** Command-line flags override environment variables. + +### Required Sentry Token Scopes + +To utilize the `stdio` transport, create a User Auth Token in Sentry with these scopes: + +**Minimum (read-only)**: +- `org:read`, `project:read`, `team:read`, `event:read` + +**Additional (for write operations)**: +- `event:write` - Required for `triage` skill +- `project:write`, `team:write` - Required for `project-management` skill + +The MCP server will automatically request the appropriate scopes based on your granted skills. + +### Migration from Scopes (Deprecated) + +> ⚠️ **Deprecated**: The `--scopes` and `--add-scopes` flags are deprecated. Use `--skills` instead. -Configure this variable only when you operate a self-hosted Sentry deployment; -it is not needed for Sentry SaaS. +If you're currently using scopes: -**Important:** The `MCP_SCOPES` environment variable or `--scopes` flag completely replaces the default scopes. Use `MCP_ADD_SCOPES` or `--add-scopes` if you want to keep the default read-only permissions and add additional ones. +```shell +# OLD (deprecated) +npx @sentry/mcp-server --access-token=TOKEN --scopes=org:read,event:write + +# NEW (recommended) +npx @sentry/mcp-server --access-token=TOKEN --skills=inspect,triage +``` The host configuration accepts two distinct formats: diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index f0ef4abda..9693edb1f 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -13,9 +13,7 @@ "author": "Sentry", "description": "Sentry MCP Server", "homepage": "https://github.com/getsentry/sentry-mcp", - "keywords": [ - "sentry" - ], + "keywords": ["sentry"], "bugs": { "url": "https://github.com/getsentry/sentry-mcp/issues" }, @@ -26,9 +24,7 @@ "bin": { "sentry-mcp": "./dist/index.js" }, - "files": [ - "./dist/*" - ], + "files": ["./dist/*"], "exports": { ".": { "types": "./dist/index.ts", @@ -58,6 +54,14 @@ "types": "./dist/permissions.ts", "default": "./dist/permissions.js" }, + "./skills": { + "types": "./dist/skills.ts", + "default": "./dist/skills.js" + }, + "./tools": { + "types": "./dist/tools/index.ts", + "default": "./dist/tools/index.js" + }, "./transports/stdio": { "types": "./dist/transports/stdio.ts", "default": "./dist/transports/stdio.js" @@ -70,6 +74,10 @@ "types": "./dist/toolDefinitions.ts", "default": "./dist/toolDefinitions.js" }, + "./skillDefinitions": { + "types": "./dist/skillDefinitions.ts", + "default": "./dist/skillDefinitions.js" + }, "./types": { "types": "./dist/types.ts", "default": "./dist/types.js" @@ -116,7 +124,8 @@ "tsc": "tsc --noEmit", "generate-definitions": "tsx scripts/generate-definitions.ts", "generate-otel-namespaces": "tsx scripts/generate-otel-namespaces.ts", - "measure-tokens": "tsx scripts/measure-token-cost.ts" + "measure-tokens": "tsx scripts/measure-token-cost.ts", + "validate-skills": "tsx scripts/validate-skills-mapping.ts" }, "devDependencies": { "@sentry/mcp-server-mocks": "workspace:*", diff --git a/packages/mcp-server/scripts/generate-definitions.ts b/packages/mcp-server/scripts/generate-definitions.ts index 353394db4..c713c1cac 100644 --- a/packages/mcp-server/scripts/generate-definitions.ts +++ b/packages/mcp-server/scripts/generate-definitions.ts @@ -1,6 +1,6 @@ #!/usr/bin/env tsx /** - * Generate tool definitions JSON for external consumption. + * Generate tool and skill definitions JSON for external consumption. * * Outputs to src/ so they can be bundled and imported by clients and the Cloudflare app. */ @@ -15,6 +15,7 @@ const __dirname = path.dirname(__filename); // Lazy imports of server modules to avoid type bleed const toolsModule = await import("../src/tools/index.ts"); +const skillsModule = await import("../src/skills.ts"); function writeJson(file: string, data: unknown) { fs.writeFileSync(file, JSON.stringify(data, null, 2)); @@ -84,17 +85,47 @@ function generateToolDefinitions() { return defs.sort(byName); } +// Skills +async function generateSkillDefinitions() { + const getSkillsArrayWithCounts = + skillsModule.getSkillsArrayWithCounts as () => Promise< + Array<{ + id: string; + name: string; + description: string; + defaultEnabled: boolean; + order: number; + toolCount?: number; + }> + >; + + if (typeof getSkillsArrayWithCounts !== "function") { + throw new Error( + "Failed to import getSkillsArrayWithCounts from src/skills.ts", + ); + } + + const skills = await getSkillsArrayWithCounts(); + + // Return sorted by order (already sorted but being explicit) + return skills.sort((a, b) => a.order - b.order); +} + async function main() { try { - console.log("Generating tool definitions..."); + console.log("Generating tool and skill definitions..."); const outDir = path.join(__dirname, "../src"); ensureDirExists(outDir); const tools = generateToolDefinitions(); + const skills = await generateSkillDefinitions(); writeJson(path.join(outDir, "toolDefinitions.json"), tools); + writeJson(path.join(outDir, "skillDefinitions.json"), skills); - console.log(`✅ Generated: tools(${tools.length})`); + console.log( + `✅ Generated: tools(${tools.length}), skills(${skills.length})`, + ); } catch (error) { const err = error as Error; console.error("[ERROR]", err.message, err.stack); diff --git a/packages/mcp-server/scripts/validate-skills-mapping.ts b/packages/mcp-server/scripts/validate-skills-mapping.ts new file mode 100755 index 000000000..325d2c801 --- /dev/null +++ b/packages/mcp-server/scripts/validate-skills-mapping.ts @@ -0,0 +1,236 @@ +#!/usr/bin/env tsx +/** + * Validation script for skills-to-scopes mapping + * + * This script verifies that: + * 1. Each skill enables the expected set of tools + * 2. The scopes calculated from skills match expected permissions + * 3. No tools are left inaccessible by any skill combination + * 4. The mapping is consistent with the design document + */ + +import { SKILLS, getScopesForSkills, type Skill } from "../src/skills.js"; +import type { Scope } from "../src/permissions.js"; +import tools from "../src/tools/index.js"; + +// Color codes for terminal output +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", +}; + +interface ValidationResult { + skill: Skill; + enabledTools: string[]; + requiredScopes: Set; +} + +/** + * Calculate which tools are enabled by a given skill + */ +function getToolsForSkill(skill: Skill): string[] { + const enabledTools: string[] = []; + + for (const [toolName, tool] of Object.entries(tools)) { + if (tool.requiredSkills.includes(skill)) { + enabledTools.push(toolName); + } + } + + return enabledTools.sort(); +} + +/** + * Validate skills-to-tools-to-scopes mapping + */ +async function validateMapping(): Promise { + const results: ValidationResult[] = []; + + for (const skillId of Object.keys(SKILLS) as Skill[]) { + const enabledTools = getToolsForSkill(skillId); + const requiredScopes = await getScopesForSkills(new Set([skillId])); + + results.push({ + skill: skillId, + enabledTools, + requiredScopes, + }); + } + + return results; +} + +/** + * Check for tools that are not accessible by any skill + * Excludes tools that are intentionally agent-mode only or foundational + */ +function findOrphanedTools(): string[] { + // Tools that are intentionally not accessible via standard skills + const AGENT_MODE_ONLY_TOOLS = ["use_sentry"]; + + // Foundational tools that are always available (no skill requirement) + const FOUNDATIONAL_TOOLS = ["find_organizations", "find_projects", "whoami"]; + + const orphanedTools: string[] = []; + + for (const [toolName, tool] of Object.entries(tools)) { + // Skip agent-mode-only tools + if (AGENT_MODE_ONLY_TOOLS.includes(toolName)) { + continue; + } + + // Skip foundational tools (they intentionally have no required skills) + if (FOUNDATIONAL_TOOLS.includes(toolName)) { + continue; + } + + // Check if tool has no required skills (orphaned) + if (tool.requiredSkills.length === 0) { + orphanedTools.push(toolName); + } + } + + return orphanedTools; +} + +/** + * Display validation results + */ +function displayResults(results: ValidationResult[]): void { + console.log( + `${colors.bright}${colors.blue}Skills-to-Tools-to-Scopes Validation${colors.reset}\n`, + ); + console.log("━".repeat(80)); + + for (const result of results) { + const skill = SKILLS[result.skill]; + const scopeList = Array.from(result.requiredScopes).sort(); + + console.log( + `\n${colors.bright}${colors.cyan}Skill: ${skill.name}${colors.reset}`, + ); + console.log(` ID: ${result.skill}`); + console.log(` Default: ${skill.defaultEnabled ? "Yes" : "No"}`); + console.log( + `\n ${colors.bright}Enabled Tools (${result.enabledTools.length}):${colors.reset}`, + ); + + if (result.enabledTools.length === 0) { + console.log(` ${colors.yellow}⚠️ No tools enabled${colors.reset}`); + } else { + for (const toolName of result.enabledTools) { + console.log(` - ${toolName}`); + } + } + + console.log( + `\n ${colors.bright}Required Scopes (${scopeList.length}):${colors.reset}`, + ); + for (const scope of scopeList) { + console.log(` - ${scope}`); + } + } + + console.log(`\n${"━".repeat(80)}`); +} + +/** + * Display orphaned tools warning + */ +function displayOrphanedTools(orphanedTools: string[]): void { + if (orphanedTools.length > 0) { + console.log( + `\n${colors.bright}${colors.yellow}⚠️ Warning: Orphaned Tools${colors.reset}`, + ); + console.log("The following tools are not accessible by any skill:\n"); + for (const toolName of orphanedTools) { + console.log(` - ${toolName}`); + } + console.log(""); + } +} + +/** + * Display summary statistics + */ +function displaySummary(results: ValidationResult[]): void { + // Special categories of tools + const AGENT_MODE_ONLY_TOOLS = ["use_sentry"]; + const FOUNDATIONAL_TOOLS = ["find_organizations", "find_projects", "whoami"]; + + const totalTools = Object.keys(tools).length; + const toolsWithSkills = new Set(); + + for (const result of results) { + for (const tool of result.enabledTools) { + toolsWithSkills.add(tool); + } + } + + // Total accessible = union of all accessible tool categories + const allAccessibleTools = new Set([ + ...toolsWithSkills, + ...FOUNDATIONAL_TOOLS, + ...AGENT_MODE_ONLY_TOOLS, + ]); + const totalAccessibleTools = allAccessibleTools.size; + + const defaultSkills = Object.values(SKILLS).filter( + (s) => s.defaultEnabled, + ).length; + const optionalSkills = Object.values(SKILLS).filter( + (s) => !s.defaultEnabled, + ).length; + + console.log(`${colors.bright}Summary:${colors.reset}`); + console.log( + ` Total Skills: ${Object.keys(SKILLS).length} (${defaultSkills} default, ${optionalSkills} optional)`, + ); + console.log(` Total Tools: ${totalTools}`); + console.log(` Tools with Skills: ${toolsWithSkills.size}`); + console.log( + ` Foundational Tools: ${FOUNDATIONAL_TOOLS.length} (always available)`, + ); + console.log(` Agent-mode-only Tools: ${AGENT_MODE_ONLY_TOOLS.length}`); + console.log(` Orphaned Tools: ${totalTools - totalAccessibleTools}`); + + if (totalAccessibleTools === totalTools) { + console.log(`\n ${colors.green}✓ All tools are accessible${colors.reset}`); + } else { + console.log( + `\n ${colors.yellow}⚠️ ${totalTools - totalAccessibleTools} tool(s) are not accessible${colors.reset}`, + ); + } +} + +// Main execution +async function main(): Promise { + try { + const results = await validateMapping(); + displayResults(results); + + const orphanedTools = findOrphanedTools(); + displayOrphanedTools(orphanedTools); + + displaySummary(results); + + console.log(""); + + // Exit with error if there are orphaned tools + if (orphanedTools.length > 0) { + process.exit(1); + } + } catch (error) { + console.error( + `${colors.yellow}Error during validation:${colors.reset}`, + error, + ); + process.exit(1); + } +} + +main(); diff --git a/packages/mcp-server/src/cli/parse.test.ts b/packages/mcp-server/src/cli/parse.test.ts index 4b2979e72..80e1d0998 100644 --- a/packages/mcp-server/src/cli/parse.test.ts +++ b/packages/mcp-server/src/cli/parse.test.ts @@ -30,13 +30,40 @@ describe("cli/parseArgv", () => { expect(parsed.unknownArgs).toEqual([]); }); + it("parses skills flags", () => { + const parsed = parseArgv(["--access-token=tok", "--skills=inspect,triage"]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.skills).toBe("inspect,triage"); + }); + it("collects unknown args", () => { const parsed = parseArgv(["--unknown", "--another=1"]); expect(parsed.unknownArgs.length).toBeGreaterThan(0); }); }); -describe("cli/parseEnv + merge", () => { +describe("cli/parseEnv", () => { + it("parses environment variables including skills", () => { + const env = parseEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + SENTRY_HOST: "envhost", + MCP_URL: "envmcp", + SENTRY_DSN: "envdsn", + MCP_SCOPES: "org:read", + MCP_ADD_SCOPES: "event:write", + MCP_SKILLS: "inspect,triage", + } as any); + expect(env.accessToken).toBe("envtok"); + expect(env.host).toBe("envhost"); + expect(env.mcpUrl).toBe("envmcp"); + expect(env.sentryDsn).toBe("envdsn"); + expect(env.scopes).toBe("org:read"); + expect(env.addScopes).toBe("event:write"); + expect(env.skills).toBe("inspect,triage"); + }); +}); + +describe("cli/merge", () => { it("applies precedence: CLI over env", () => { const env = parseEnv({ SENTRY_ACCESS_TOKEN: "envtok", @@ -64,4 +91,24 @@ describe("cli/parseEnv + merge", () => { expect(merged.scopes).toBe("org:admin"); expect(merged.addScopes).toBe("project:write"); }); + + it("applies precedence for skills: CLI over env", () => { + const env = parseEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect", + } as any); + const cli = parseArgv(["--access-token=clitok", "--skills=inspect,triage"]); + const merged = merge(cli, env); + expect(merged.skills).toBe("inspect,triage"); + }); + + it("falls back to env when CLI skills not provided", () => { + const env = parseEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect,triage", + } as any); + const cli = parseArgv(["--access-token=clitok"]); + const merged = merge(cli, env); + expect(merged.skills).toBe("inspect,triage"); + }); }); diff --git a/packages/mcp-server/src/cli/parse.ts b/packages/mcp-server/src/cli/parse.ts index 3b19d5d25..b194de912 100644 --- a/packages/mcp-server/src/cli/parse.ts +++ b/packages/mcp-server/src/cli/parse.ts @@ -15,6 +15,7 @@ export function parseArgv(argv: string[]): CliArgs { scopes: { type: "string" as const }, "add-scopes": { type: "string" as const }, "all-scopes": { type: "boolean" as const }, + skills: { type: "string" as const }, agent: { type: "boolean" as const }, help: { type: "boolean" as const, short: "h" as const }, version: { type: "boolean" as const, short: "v" as const }, @@ -60,6 +61,7 @@ export function parseArgv(argv: string[]): CliArgs { scopes: values.scopes as string | undefined, addScopes: values["add-scopes"] as string | undefined, allScopes: (values["all-scopes"] as boolean | undefined) === true, + skills: values.skills as string | undefined, agent: (values.agent as boolean | undefined) === true, help: (values.help as boolean | undefined) === true, version: (values.version as boolean | undefined) === true, @@ -76,9 +78,27 @@ export function parseEnv(env: NodeJS.ProcessEnv): EnvArgs { if (env.MCP_URL) fromEnv.mcpUrl = env.MCP_URL; if (env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN) fromEnv.sentryDsn = env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN; + if (env.OPENAI_MODEL) fromEnv.openaiModel = env.OPENAI_MODEL; - if (env.MCP_SCOPES) fromEnv.scopes = env.MCP_SCOPES; - if (env.MCP_ADD_SCOPES) fromEnv.addScopes = env.MCP_ADD_SCOPES; + + // LEGACY - deprecated environment variables + if (env.MCP_SCOPES) { + fromEnv.scopes = env.MCP_SCOPES; + console.warn("⚠️ Warning: MCP_SCOPES environment variable is deprecated."); + console.warn(" Consider using MCP_SKILLS instead."); + console.warn(""); + } + if (env.MCP_ADD_SCOPES) { + fromEnv.addScopes = env.MCP_ADD_SCOPES; + console.warn( + "⚠️ Warning: MCP_ADD_SCOPES environment variable is deprecated.", + ); + console.warn(" Consider using MCP_SKILLS instead."); + console.warn(""); + } + + // NEW - primary authorization method + if (env.MCP_SKILLS) fromEnv.skills = env.MCP_SKILLS; return fromEnv; } @@ -97,6 +117,8 @@ export function merge(cli: CliArgs, env: EnvArgs): MergedArgs { scopes: cli.scopes ?? env.scopes, addScopes: cli.addScopes ?? env.addScopes, allScopes: cli.allScopes === true, + // Skills precedence: CLI skills override env + skills: cli.skills ?? env.skills, agent: cli.agent === true, organizationSlug: cli.organizationSlug, projectSlug: cli.projectSlug, @@ -109,5 +131,6 @@ export function merge(cli: CliArgs, env: EnvArgs): MergedArgs { if (cli.scopes) merged.addScopes = cli.addScopes; // If CLI provided add-scopes, ensure scopes override isn't pulled from env if (cli.addScopes) merged.scopes = cli.scopes; + return merged; } diff --git a/packages/mcp-server/src/cli/resolve.test.ts b/packages/mcp-server/src/cli/resolve.test.ts index d7b660565..1b67884d0 100644 --- a/packages/mcp-server/src/cli/resolve.test.ts +++ b/packages/mcp-server/src/cli/resolve.test.ts @@ -81,4 +81,62 @@ describe("cli/finalize", () => { finalize({ accessToken: "tok", url: "http://bad", unknownArgs: [] }), ).toThrow(/must be a full HTTPS URL/); }); + + // Skills tests + it("throws on invalid skills", () => { + expect(() => + finalize({ + accessToken: "tok", + skills: "invalid-skill", + unknownArgs: [], + }), + ).toThrow(/Invalid skills provided: invalid-skill/); + }); + + it("validates multiple skills and reports all invalid ones", () => { + expect(() => + finalize({ + accessToken: "tok", + skills: "inspect,invalid1,triage,invalid2", + unknownArgs: [], + }), + ).toThrow(/Invalid skills provided: invalid1, invalid2/); + }); + + it("resolves valid skills in override mode (--skills)", () => { + const cfg = finalize({ + accessToken: "tok", + skills: "inspect,triage", + unknownArgs: [], + }); + expect(cfg.finalSkills?.has("inspect")).toBe(true); + expect(cfg.finalSkills?.has("triage")).toBe(true); + expect(cfg.finalSkills?.size).toBe(2); + // Should not include defaults + expect(cfg.finalSkills?.has("docs")).toBe(false); + }); + + it("throws on empty skills after validation", () => { + expect(() => + finalize({ + accessToken: "tok", + skills: "invalid1,invalid2", + unknownArgs: [], + }), + ).toThrow(/Invalid skills provided/); + }); + + it("grants all skills when no skills specified", () => { + const cfg = finalize({ + accessToken: "tok", + unknownArgs: [], + }); + expect(cfg.finalSkills).toBeDefined(); + expect(cfg.finalSkills?.size).toBe(5); // All skills: inspect, triage, project-management, seer, docs + expect(cfg.finalSkills?.has("inspect")).toBe(true); + expect(cfg.finalSkills?.has("triage")).toBe(true); + expect(cfg.finalSkills?.has("project-management")).toBe(true); + expect(cfg.finalSkills?.has("seer")).toBe(true); + expect(cfg.finalSkills?.has("docs")).toBe(true); + }); }); diff --git a/packages/mcp-server/src/cli/resolve.ts b/packages/mcp-server/src/cli/resolve.ts index 4a19c22f8..ea0d5b1db 100644 --- a/packages/mcp-server/src/cli/resolve.ts +++ b/packages/mcp-server/src/cli/resolve.ts @@ -4,6 +4,7 @@ import { resolveScopes, type Scope, } from "../permissions"; +import { parseSkills, SKILLS, type Skill } from "../skills"; import { DEFAULT_SCOPES } from "../constants"; import { validateAndParseSentryUrlThrows, @@ -17,6 +18,15 @@ export function formatInvalid(invalid: string[], envName?: string): string { return `Error: ${where}: ${invalid.join(", ")}\nAvailable scopes: ${ALL_SCOPES.join(", ")}`; } +export function formatInvalidSkills( + invalid: string[], + envName?: string, +): string { + const where = envName ? `${envName} provided` : "Invalid skills provided"; + const allSkills = Object.keys(SKILLS).join(", "); + return `Error: ${where}: ${invalid.join(", ")}\nAvailable skills: ${allSkills}`; +} + export function finalize(input: MergedArgs): ResolvedConfig { // Access token required if (!input.accessToken) { @@ -34,11 +44,38 @@ export function finalize(input: MergedArgs): ResolvedConfig { sentryHost = input.host; } - // Scopes resolution + // Scopes resolution (LEGACY - deprecated in favor of skills) let finalScopes: Set | undefined = undefined; if (input.allScopes) { + console.warn( + "⚠️ Warning: --all-scopes is deprecated. Consider using skills instead:", + ); + console.warn(" --skills=inspect,triage,project-management,seer,docs"); + console.warn(" Learn more about skills in the documentation."); + console.warn(""); finalScopes = new Set(ALL_SCOPES as ReadonlyArray); } else if (input.scopes || input.addScopes) { + // Show deprecation warning + if (input.scopes) { + console.warn( + "⚠️ Warning: --scopes is deprecated. Consider using skills instead:", + ); + console.warn( + " For example, instead of --scopes=event:write,project:write", + ); + console.warn(" Use: --skills=triage,project-management"); + console.warn(" Learn more about skills in the documentation."); + console.warn(""); + } else if (input.addScopes) { + console.warn( + "⚠️ Warning: --add-scopes is deprecated. Consider using --skills instead:", + ); + console.warn(" For example, instead of --add-scopes=event:write"); + console.warn(" Use: --skills=triage"); + console.warn(" Learn more about skills in the documentation."); + console.warn(""); + } + // Strict validation: any invalid token is an error if (input.scopes) { const { valid, invalid } = parseScopes(input.scopes); @@ -68,6 +105,40 @@ export function finalize(input: MergedArgs): ResolvedConfig { } } + // Skills resolution + // + // IMPORTANT: stdio (CLI) intentionally defaults to ALL skills when no --skills flag is provided + // + // This differs from the OAuth flow, which requires explicit user selection: + // - stdio/CLI: Non-interactive, defaults to ALL skills (inspect, docs, seer, triage, project-management) + // - OAuth: Interactive, requires user to explicitly select skills (with sensible defaults pre-checked) + // + // Rationale: + // We don't want the MCP to break if users don't specify skills. stdio is typically used in + // local development and CI/CD environments where maximum access by default is expected. + // OAuth is used in multi-tenant hosted environments where users should consciously grant + // permissions on a per-app basis. + // + // For OAuth validation that enforces minimum 1 skill selection, see: + // packages/mcp-cloudflare/src/server/oauth/routes/callback.ts (lines 234-248) + // + let finalSkills: Set | undefined = undefined; + if (input.skills) { + // Override: use only the specified skills + const { valid, invalid } = parseSkills(input.skills); + if (invalid.length > 0) { + throw new Error(formatInvalidSkills(invalid)); + } + if (valid.size === 0) { + throw new Error("Error: Invalid skills provided. No valid skills found."); + } + finalSkills = valid; + } else { + // Default: grant ALL skills when no flag is provided (see comment block above for rationale) + const allSkills = Object.keys(SKILLS) as Skill[]; + finalSkills = new Set(allSkills); + } + const resolvedOpenAiBaseUrl = input.openaiBaseUrl ? validateOpenAiBaseUrlThrows(input.openaiBaseUrl) : undefined; @@ -80,6 +151,7 @@ export function finalize(input: MergedArgs): ResolvedConfig { openaiBaseUrl: resolvedOpenAiBaseUrl, openaiModel: input.openaiModel, finalScopes, + finalSkills, organizationSlug: input.organizationSlug, projectSlug: input.projectSlug, }; diff --git a/packages/mcp-server/src/cli/types.ts b/packages/mcp-server/src/cli/types.ts index 46a4130a4..5c6818f7e 100644 --- a/packages/mcp-server/src/cli/types.ts +++ b/packages/mcp-server/src/cli/types.ts @@ -1,4 +1,5 @@ import type { Scope } from "../permissions"; +import type { Skill } from "../skills"; export type CliArgs = { accessToken?: string; @@ -8,9 +9,10 @@ export type CliArgs = { sentryDsn?: string; openaiBaseUrl?: string; openaiModel?: string; - scopes?: string; - addScopes?: string; - allScopes?: boolean; + scopes?: string; // LEGACY - for backward compatibility + addScopes?: string; // LEGACY - for backward compatibility + allScopes?: boolean; // LEGACY - for backward compatibility + skills?: string; // NEW - primary authorization method agent?: boolean; organizationSlug?: string; projectSlug?: string; @@ -26,8 +28,9 @@ export type EnvArgs = { mcpUrl?: string; sentryDsn?: string; openaiModel?: string; - scopes?: string; - addScopes?: string; + scopes?: string; // LEGACY - for backward compatibility + addScopes?: string; // LEGACY - for backward compatibility + skills?: string; // NEW - primary authorization method }; export type MergedArgs = { @@ -38,9 +41,10 @@ export type MergedArgs = { sentryDsn?: string; openaiBaseUrl?: string; openaiModel?: string; - scopes?: string; - addScopes?: string; - allScopes?: boolean; + scopes?: string; // LEGACY - for backward compatibility + addScopes?: string; // LEGACY - for backward compatibility + allScopes?: boolean; // LEGACY - for backward compatibility + skills?: string; // NEW - primary authorization method agent?: boolean; organizationSlug?: string; projectSlug?: string; @@ -56,7 +60,8 @@ export type ResolvedConfig = { sentryDsn?: string; openaiBaseUrl?: string; openaiModel?: string; - finalScopes?: Set; + finalScopes?: Set; // LEGACY - for backward compatibility + finalSkills?: Set; // NEW - primary authorization method organizationSlug?: string; projectSlug?: string; }; diff --git a/packages/mcp-server/src/cli/usage.ts b/packages/mcp-server/src/cli/usage.ts index 85965a78c..bf92d6d3f 100644 --- a/packages/mcp-server/src/cli/usage.ts +++ b/packages/mcp-server/src/cli/usage.ts @@ -1,9 +1,12 @@ import type { Scope } from "../permissions"; +import type { Skill } from "../skills"; export function buildUsage( packageName: string, - defaults: ReadonlyArray, - all: ReadonlyArray, + defaultScopes: ReadonlyArray, + allScopes: ReadonlyArray, + defaultSkills: ReadonlyArray, + allSkills: ReadonlyArray, ): string { return `Usage: ${packageName} --access-token= [--host=] @@ -21,16 +24,22 @@ Session constraints: --organization-slug Force all calls to an organization --project-slug Optional project constraint -Scope controls: +Skill controls (recommended): + --skills Specify which skills to grant (default: all skills) + +All skills: ${allSkills.join(", ")} + +Scope controls (legacy - deprecated, use skills instead): --scopes Override default scopes --add-scopes Add scopes to defaults --all-scopes Grant every available scope -Defaults: ${defaults.join(", ")} -All scopes: ${all.join(", ")} +Default scopes: ${defaultScopes.join(", ")} +All scopes: ${allScopes.join(", ")} Examples: ${packageName} --access-token=TOKEN + ${packageName} --access-token=TOKEN --skills=inspect,triage ${packageName} --access-token=TOKEN --host=sentry.example.com ${packageName} --access-token=TOKEN --openai-model=o1-mini ${packageName} --access-token=TOKEN --openai-base-url=https://proxy.example.com/v1`; diff --git a/packages/mcp-server/src/constants.ts b/packages/mcp-server/src/constants.ts index 16c0fad0b..79a9fd35e 100644 --- a/packages/mcp-server/src/constants.ts +++ b/packages/mcp-server/src/constants.ts @@ -179,5 +179,9 @@ export const DEFAULT_SCOPES = [ "event:read", ] as const; +// Re-export DEFAULT_SKILLS from skills.ts for convenience +// (Skills are the new user-facing authorization system) +export { DEFAULT_SKILLS } from "./skills"; + // Note: All scopes are now exported from permissions.ts to avoid pulling this // heavy constants module into scope-only consumers. diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 6a5f56d03..c6c0f2df3 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -23,12 +23,22 @@ import { parseArgv, parseEnv, merge } from "./cli/parse"; import { finalize } from "./cli/resolve"; import { sentryBeforeSend } from "./telem/sentry"; import { ALL_SCOPES } from "./permissions"; -import { DEFAULT_SCOPES } from "./constants"; +import { DEFAULT_SCOPES, DEFAULT_SKILLS } from "./constants"; +import { SKILLS } from "./skills"; import { setOpenAIBaseUrl } from "./internal/agents/openai-provider"; import agentTools from "./tools/agent-tools"; const packageName = "@sentry/mcp-server"; -const usageText = buildUsage(packageName, DEFAULT_SCOPES, ALL_SCOPES); +const allSkills = Object.keys(SKILLS) as ReadonlyArray< + (typeof SKILLS)[keyof typeof SKILLS]["id"] +>; +const usageText = buildUsage( + packageName, + DEFAULT_SCOPES, + ALL_SCOPES, + DEFAULT_SKILLS, + allSkills, +); function die(message: string): never { console.error(message); @@ -121,6 +131,7 @@ const SENTRY_TIMEOUT = 5000; // 5 seconds const context = { accessToken: cfg.accessToken, grantedScopes: cfg.finalScopes, + grantedSkills: cfg.finalSkills, constraints: { organizationSlug: cfg.organizationSlug ?? null, projectSlug: cfg.projectSlug ?? null, diff --git a/packages/mcp-server/src/permissions.ts b/packages/mcp-server/src/permissions.ts index 14543e06a..3af95c0a1 100644 --- a/packages/mcp-server/src/permissions.ts +++ b/packages/mcp-server/src/permissions.ts @@ -25,9 +25,7 @@ export type Scope = | "event:read" // Read events and issues | "event:write" // Update issues (includes read) | "event:admin" // Delete issues (includes write and read) - | "project:releases" // Access release endpoints - | "seer" // Virtual scope: Use Seer analysis (not respected by upstream Sentry API) - | "docs"; // Virtual scope: Access Sentry documentation (not respected by upstream Sentry API) + | "project:releases"; // Access release endpoints /** * Scope hierarchy - higher scopes include lower ones @@ -60,10 +58,6 @@ const SCOPE_HIERARCHY: Record> = { // Special scopes "project:releases": new Set(["project:releases"]), - - // Virtual scopes (not respected by upstream Sentry API) - seer: new Set(["seer"]), - docs: new Set(["docs"]), }; /** @@ -119,8 +113,6 @@ export const SCOPE_DESCRIPTIONS: Record = { "event:write": "Update and manage issues", "event:admin": "Delete issues", "project:releases": "Access release information", - seer: "Use Seer for issue analysis (may incur costs)", - docs: "Access Sentry documentation", }; /** diff --git a/packages/mcp-server/src/server-context.test.ts b/packages/mcp-server/src/server-context.test.ts deleted file mode 100644 index 7e4b36780..000000000 --- a/packages/mcp-server/src/server-context.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { buildServer } from "./server"; -import type { ServerContext } from "./types"; - -const mockContext: ServerContext = { - accessToken: "test-token", - sentryHost: "sentry.io", - constraints: {}, -}; - -describe("server context resolution", () => { - describe("buildServer", () => { - it("creates server successfully with context", () => { - const server = buildServer({ context: mockContext }); - - // Server should be created and configured successfully - expect(server).toBeDefined(); - }); - - it("accepts onToolComplete callback", () => { - let callbackExecuted = false; - - const server = buildServer({ - context: mockContext, - onToolComplete: () => { - callbackExecuted = true; - }, - }); - - // The callback should be set up (actual execution happens after tool calls) - expect(server).toBeDefined(); - }); - }); -}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 625b0aa1d..4c8d8b88c 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -37,8 +37,9 @@ import { import { logIssue, type LogIssueOptions } from "./telem/logging"; import { formatErrorForUser } from "./internal/error-handling"; import { LIB_VERSION } from "./version"; -import { DEFAULT_SCOPES, MCP_SERVER_NAME } from "./constants"; +import { MCP_SERVER_NAME } from "./constants"; import { isToolAllowed, type Scope } from "./permissions"; +import { hasRequiredSkills, type Skill } from "./skills"; import { getConstraintParametersToInject, getConstraintKeysToFilter, @@ -118,11 +119,11 @@ export function buildServer({ } /** - * Configures an MCP server with tools filtered by granted scopes. + * Configures an MCP server with tools filtered by granted skills or scopes. * * Internal function used by buildServer(). Use buildServer() instead for most cases. - * Tools are filtered at registration time based on grantedScopes, and context is - * captured in closures for tool handler execution. + * Tools are filtered at registration time based on grantedSkills OR grantedScopes + * (either system can grant access), and context is captured in closures for tool handler execution. */ function configureServer({ server, @@ -138,10 +139,14 @@ function configureServer({ // Use custom tools if provided, otherwise use default tools const toolsToRegister = customTools ?? tools; - // Get granted scopes from context for tool filtering - const grantedScopes: Set = context.grantedScopes + // Get granted skills and scopes from context for tool filtering + const grantedSkills: Set | undefined = context.grantedSkills + ? new Set(context.grantedSkills) + : undefined; + + const grantedScopes: Set | undefined = context.grantedScopes ? new Set(context.grantedScopes) - : new Set(DEFAULT_SCOPES); + : undefined; server.server.onerror = (error) => { const transportLogOptions: LogIssueOptions = { @@ -157,9 +162,62 @@ function configureServer({ }; for (const [toolKey, tool] of Object.entries(toolsToRegister)) { - // Filter tools BEFORE registration based on granted scopes - if (!isToolAllowed(tool.requiredScopes, grantedScopes)) { - continue; // Skip this tool entirely + /** + * Authorization System Precedence + * ================================ + * + * The server supports two authorization systems: + * 1. **Skills System (NEW)** - User-facing permission groups (inspect, triage, etc.) + * 2. **Scopes System (LEGACY)** - Low-level API permissions (event:read, project:write, etc.) + * + * IMPORTANT: These systems are **MUTUALLY EXCLUSIVE** - only one is active per session: + * + * ## Skills Mode (when grantedSkills is set): + * - ONLY skills are checked (scopes are ignored) + * - Tool must have non-empty `requiredSkills` array to be exposed + * - Empty `requiredSkills: []` means intentionally excluded from skills system + * - Authorization: `allowed = hasRequiredSkills(grantedSkills, tool.requiredSkills)` + * + * ## Scopes Mode (when grantedSkills is NOT set, but grantedScopes is set): + * - Falls back to legacy scope checking + * - Empty `requiredScopes: []` means no scopes required (always allowed) + * - Authorization: `allowed = isToolAllowed(tool.requiredScopes, grantedScopes)` + * + * ## Tool Visibility: + * - If not allowed by active authorization system: tool is NOT registered + * - Only registered tools are visible to MCP clients + * + * ## Examples: + * ```typescript + * // Tool available in "triage" skill only: + * { requiredSkills: ["triage"], requiredScopes: ["event:write"] } + * + * // Tool available to ALL skills (foundational tool like whoami): + * { requiredSkills: ALL_SKILLS, requiredScopes: [] } + * + * // Tool excluded from skills system (like use_sentry in agent mode): + * { requiredSkills: [], requiredScopes: [] } + * ``` + */ + let allowed = false; + + // Skills system takes precedence when set + if (grantedSkills) { + // Tool must have non-empty requiredSkills to be exposed in skills mode + if (tool.requiredSkills && tool.requiredSkills.length > 0) { + allowed = hasRequiredSkills(grantedSkills, tool.requiredSkills); + } + // Empty requiredSkills means NOT exposed via skills system + } + // Legacy fallback: Check scopes if not using skills + else if (grantedScopes) { + // isToolAllowed handles empty requiredScopes correctly (returns true) + allowed = isToolAllowed(tool.requiredScopes, grantedScopes); + } + + // Skip tool if not allowed by active authorization system + if (!allowed) { + continue; } // Filter out constraint parameters from schema that will be auto-injected diff --git a/packages/mcp-server/src/skillDefinitions.json b/packages/mcp-server/src/skillDefinitions.json index 04b543b54..68b54ff36 100644 --- a/packages/mcp-server/src/skillDefinitions.json +++ b/packages/mcp-server/src/skillDefinitions.json @@ -39,4 +39,4 @@ "order": 5, "toolCount": 9 } -] +] \ No newline at end of file diff --git a/packages/mcp-server/src/skillDefinitions.ts b/packages/mcp-server/src/skillDefinitions.ts new file mode 100644 index 000000000..bf1c3acff --- /dev/null +++ b/packages/mcp-server/src/skillDefinitions.ts @@ -0,0 +1,15 @@ +import skillDefinitionsData from "./skillDefinitions.json"; + +// Skill definition for UI/external consumption +export interface SkillDefinition { + id: string; + name: string; + description: string; + defaultEnabled: boolean; + order: number; + toolCount?: number; +} + +const skillDefinitions = skillDefinitionsData as SkillDefinition[]; + +export default skillDefinitions; diff --git a/packages/mcp-server/src/skills.test.ts b/packages/mcp-server/src/skills.test.ts new file mode 100644 index 000000000..97fc58977 --- /dev/null +++ b/packages/mcp-server/src/skills.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import { + SKILLS, + DEFAULT_SKILLS, + isValidSkill, + parseSkills, + hasRequiredSkills, + type Skill, +} from "./skills"; + +describe("skills module", () => { + describe("SKILLS registry", () => { + it("has all expected skills", () => { + expect(SKILLS.inspect).toBeDefined(); + expect(SKILLS.triage).toBeDefined(); + expect(SKILLS["project-management"]).toBeDefined(); + expect(SKILLS.seer).toBeDefined(); + expect(SKILLS.docs).toBeDefined(); + }); + + it("includes metadata for each skill", () => { + for (const skill of Object.values(SKILLS)) { + expect(skill.id).toBeTruthy(); + expect(skill.name).toBeTruthy(); + expect(skill.description).toBeTruthy(); + expect(typeof skill.defaultEnabled).toBe("boolean"); + expect(typeof skill.order).toBe("number"); + } + }); + }); + + describe("DEFAULT_SKILLS", () => { + it("includes only default-enabled skills", () => { + expect(DEFAULT_SKILLS).toContain("inspect"); + expect(DEFAULT_SKILLS).toContain("seer"); + expect(DEFAULT_SKILLS).not.toContain("docs"); + expect(DEFAULT_SKILLS).not.toContain("triage"); + expect(DEFAULT_SKILLS).not.toContain("project-management"); + }); + + it("has exactly 2 default skills", () => { + expect(DEFAULT_SKILLS.length).toBe(2); + }); + }); + + describe("isValidSkill", () => { + it("returns true for valid skills", () => { + expect(isValidSkill("inspect")).toBe(true); + expect(isValidSkill("triage")).toBe(true); + expect(isValidSkill("project-management")).toBe(true); + expect(isValidSkill("seer")).toBe(true); + expect(isValidSkill("docs")).toBe(true); + }); + + it("returns false for invalid skills", () => { + expect(isValidSkill("invalid")).toBe(false); + expect(isValidSkill("")).toBe(false); + expect(isValidSkill("INSPECT")).toBe(false); + }); + }); + + describe("parseSkills", () => { + it("parses comma-separated string", () => { + const { valid, invalid } = parseSkills("inspect,triage,docs"); + expect(valid.size).toBe(3); + expect(valid.has("inspect")).toBe(true); + expect(valid.has("triage")).toBe(true); + expect(valid.has("docs")).toBe(true); + expect(invalid.length).toBe(0); + }); + + it("parses array of skills", () => { + const { valid, invalid } = parseSkills(["inspect", "triage"]); + expect(valid.size).toBe(2); + expect(valid.has("inspect")).toBe(true); + expect(valid.has("triage")).toBe(true); + expect(invalid.length).toBe(0); + }); + + it("separates valid and invalid skills", () => { + const { valid, invalid } = parseSkills( + "inspect,invalid1,triage,invalid2", + ); + expect(valid.size).toBe(2); + expect(valid.has("inspect")).toBe(true); + expect(valid.has("triage")).toBe(true); + expect(invalid).toEqual(["invalid1", "invalid2"]); + }); + + it("trims whitespace from skills", () => { + const { valid, invalid } = parseSkills(" inspect , triage , docs "); + expect(valid.size).toBe(3); + expect(valid.has("inspect")).toBe(true); + expect(invalid.length).toBe(0); + }); + + it("handles empty input", () => { + const { valid, invalid } = parseSkills(""); + expect(valid.size).toBe(0); + expect(invalid.length).toBe(0); + }); + + it("handles null/undefined input", () => { + const { valid, invalid } = parseSkills(null); + expect(valid.size).toBe(0); + expect(invalid.length).toBe(0); + }); + + it("ignores empty strings in array", () => { + const { valid, invalid } = parseSkills(["inspect", "", "triage"]); + expect(valid.size).toBe(2); + expect(invalid.length).toBe(0); + }); + }); + + describe("hasRequiredSkills", () => { + it("returns true when any required skill is granted", () => { + const grantedSkills = new Set(["inspect", "docs"]); + expect(hasRequiredSkills(grantedSkills, ["inspect"])).toBe(true); + expect(hasRequiredSkills(grantedSkills, ["docs"])).toBe(true); + expect(hasRequiredSkills(grantedSkills, ["inspect", "triage"])).toBe( + true, + ); + }); + + it("returns false when no required skills are granted", () => { + const grantedSkills = new Set(["inspect", "docs"]); + expect(hasRequiredSkills(grantedSkills, ["triage"])).toBe(false); + expect( + hasRequiredSkills(grantedSkills, ["triage", "project-management"]), + ).toBe(false); + }); + + it("returns false when grantedSkills is undefined", () => { + expect(hasRequiredSkills(undefined, ["inspect"])).toBe(false); + }); + + it("returns false when requiredSkills is empty", () => { + const grantedSkills = new Set(["inspect"]); + expect(hasRequiredSkills(grantedSkills, [])).toBe(false); + }); + }); +}); diff --git a/packages/mcp-server/src/skills.ts b/packages/mcp-server/src/skills.ts new file mode 100644 index 000000000..a2d39e32a --- /dev/null +++ b/packages/mcp-server/src/skills.ts @@ -0,0 +1,177 @@ +/** + * Skills: User-facing authorization system for MCP server capabilities + * + * Skills bundle related tools into functional capabilities that users can enable. + * They coexist with traditional Sentry API scopes during the transition period. + */ + +// Skill type +export type Skill = + | "inspect" + | "triage" + | "project-management" + | "seer" + | "docs"; + +// Central registry with metadata (used by OAuth UI) +export interface SkillDefinition { + id: Skill; + name: string; + description: string; + defaultEnabled: boolean; + order: number; + toolCount?: number; // Number of tools enabled by this skill (calculated dynamically) +} + +export const SKILLS: Record = { + inspect: { + id: "inspect", + name: "Inspect Issues & Events", + description: "Search for errors, analyze traces, and explore event details", + defaultEnabled: true, + order: 1, + }, + seer: { + id: "seer", + name: "Seer", + description: + "Sentry's AI debugger that helps you analyze, root cause, and fix issues", + defaultEnabled: true, + order: 2, + }, + docs: { + id: "docs", + name: "Documentation", + description: "Search and read Sentry SDK documentation", + defaultEnabled: false, + order: 3, + }, + triage: { + id: "triage", + name: "Triage Issues", + description: "Resolve, assign, and update issues", + defaultEnabled: false, + order: 4, + }, + "project-management": { + id: "project-management", + name: "Manage Projects & Teams", + description: "Create and modify projects, teams, and DSNs", + defaultEnabled: false, + order: 5, + }, +}; + +// Sorted array for UI ordering +export const SKILLS_ARRAY: SkillDefinition[] = Object.values(SKILLS).sort( + (a, b) => a.order - b.order, +); + +// Get skills with tool counts (used by build script only) +export async function getSkillsArrayWithCounts(): Promise { + // Dynamically import to avoid circular dependency + const toolsModule = await import("./tools"); + const tools = toolsModule.default; + + const counts = new Map(); + + // Initialize counts + for (const skill of Object.keys(SKILLS)) { + counts.set(skill as Skill, 0); + } + + // Count tools for each skill + for (const tool of Object.values(tools)) { + if (Array.isArray(tool.requiredSkills)) { + for (const skill of tool.requiredSkills) { + counts.set(skill as Skill, (counts.get(skill as Skill) || 0) + 1); + } + } + } + + return SKILLS_ARRAY.map((skill) => ({ + ...skill, + toolCount: counts.get(skill.id) || 0, + })); +} + +// All skills (for foundational tools that should be available to all skills) +export const ALL_SKILLS: Skill[] = Object.keys(SKILLS) as Skill[]; + +// Default skills +export const DEFAULT_SKILLS: Skill[] = SKILLS_ARRAY.filter( + (s) => s.defaultEnabled, +).map((s) => s.id); + +// Validation +export function isValidSkill(skill: string): skill is Skill { + return skill in SKILLS; +} + +// Check if tool is enabled by skills +export function hasRequiredSkills( + grantedSkills: Set | undefined, + requiredSkills: Skill[], +): boolean { + if (!grantedSkills || requiredSkills.length === 0) return false; + return requiredSkills.some((skill) => grantedSkills.has(skill)); +} + +// Parse and validate skills from input +export function parseSkills(input: unknown): { + valid: Set; + invalid: string[]; +} { + const valid = new Set(); + const invalid: string[] = []; + + if (!input) return { valid, invalid }; + + // Parse skills from string (comma-separated) or array (from JSON) + let skills: string[] = []; + if (typeof input === "string") { + skills = input.split(","); + } else if (Array.isArray(input)) { + skills = input.map((v) => (typeof v === "string" ? v : "")); + } + + for (const skill of skills) { + const trimmed = String(skill).trim(); + if (isValidSkill(trimmed)) { + valid.add(trimmed); + } else if (trimmed) { + invalid.push(trimmed); + } + } + + return { valid, invalid }; +} + +// Calculate required scopes from granted skills +export async function getScopesForSkills( + grantedSkills: Set, +): Promise> { + // Import here to avoid circular dependency at module load time + const { DEFAULT_SCOPES } = await import("./constants.js"); + const toolsModule = await import("./tools/index.js"); + const tools = toolsModule.default; + + const scopes = new Set(DEFAULT_SCOPES); + + // Iterate through all tools and collect required scopes for tools enabled by granted skills + for (const tool of Object.values(tools)) { + // Check if any of the tool's required skills are granted + const toolEnabled = tool.requiredSkills.some((reqSkill) => + grantedSkills.has(reqSkill), + ); + + // If tool is enabled by granted skills, add its required scopes + if (toolEnabled) { + for (const scope of tool.requiredScopes) { + scopes.add(scope); + } + } + } + + return scopes; +} diff --git a/packages/mcp-server/src/tools/analyze-issue-with-seer.ts b/packages/mcp-server/src/tools/analyze-issue-with-seer.ts index babc139e5..95dbe7db7 100644 --- a/packages/mcp-server/src/tools/analyze-issue-with-seer.ts +++ b/packages/mcp-server/src/tools/analyze-issue-with-seer.ts @@ -25,7 +25,8 @@ import { export default defineTool({ name: "analyze_issue_with_seer", - requiredScopes: ["seer"], + requiredSkills: ["seer"], // Only available in seer skill + requiredScopes: [], // No Sentry API scopes required - authorization via 'seer' skill description: [ "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.", "", diff --git a/packages/mcp-server/src/tools/create-dsn.ts b/packages/mcp-server/src/tools/create-dsn.ts index baa0eade5..82f7f497d 100644 --- a/packages/mcp-server/src/tools/create-dsn.ts +++ b/packages/mcp-server/src/tools/create-dsn.ts @@ -11,6 +11,7 @@ import { export default defineTool({ name: "create_dsn", + requiredSkills: ["project-management"], // Only available in project-management skill requiredScopes: ["project:write"], description: [ "Create an additional DSN for an EXISTING project.", diff --git a/packages/mcp-server/src/tools/create-project.ts b/packages/mcp-server/src/tools/create-project.ts index 42ceb9266..cd8f2aa92 100644 --- a/packages/mcp-server/src/tools/create-project.ts +++ b/packages/mcp-server/src/tools/create-project.ts @@ -14,6 +14,7 @@ import { export default defineTool({ name: "create_project", + requiredSkills: ["project-management"], // Only available in project-management skill requiredScopes: ["project:write", "team:read"], description: [ "Create a new project in Sentry (includes DSN automatically).", diff --git a/packages/mcp-server/src/tools/create-team.ts b/packages/mcp-server/src/tools/create-team.ts index 924d05010..eddad6d37 100644 --- a/packages/mcp-server/src/tools/create-team.ts +++ b/packages/mcp-server/src/tools/create-team.ts @@ -7,6 +7,7 @@ import { ParamOrganizationSlug, ParamRegionUrl } from "../schema"; export default defineTool({ name: "create_team", + requiredSkills: ["project-management"], // Only available in project-management skill requiredScopes: ["team:write"], description: [ "Create a new team in Sentry.", diff --git a/packages/mcp-server/src/tools/find-dsns.ts b/packages/mcp-server/src/tools/find-dsns.ts index 7aeac127d..9e47d7c60 100644 --- a/packages/mcp-server/src/tools/find-dsns.ts +++ b/packages/mcp-server/src/tools/find-dsns.ts @@ -10,6 +10,7 @@ import { export default defineTool({ name: "find_dsns", + requiredSkills: ["project-management"], // DSN management is part of project setup requiredScopes: ["project:read"], description: [ "List all Sentry DSNs for a specific project.", diff --git a/packages/mcp-server/src/tools/find-organizations.ts b/packages/mcp-server/src/tools/find-organizations.ts index 64a7493b5..55d9f6b80 100644 --- a/packages/mcp-server/src/tools/find-organizations.ts +++ b/packages/mcp-server/src/tools/find-organizations.ts @@ -2,11 +2,13 @@ import { defineTool } from "../internal/tool-helpers/define"; import { apiServiceFromContext } from "../internal/tool-helpers/api"; import type { ServerContext } from "../types"; import { ParamSearchQuery } from "../schema"; +import { ALL_SKILLS } from "../skills"; const RESULT_LIMIT = 25; export default defineTool({ name: "find_organizations", + requiredSkills: ALL_SKILLS, // Foundational tool - available to all skills requiredScopes: ["org:read"], description: [ "Find organizations that the user has access to in Sentry.", diff --git a/packages/mcp-server/src/tools/find-projects.ts b/packages/mcp-server/src/tools/find-projects.ts index d247df5d7..0a10094e8 100644 --- a/packages/mcp-server/src/tools/find-projects.ts +++ b/packages/mcp-server/src/tools/find-projects.ts @@ -8,11 +8,13 @@ import { ParamRegionUrl, ParamSearchQuery, } from "../schema"; +import { ALL_SKILLS } from "../skills"; const RESULT_LIMIT = 25; export default defineTool({ name: "find_projects", + requiredSkills: ALL_SKILLS, // Foundational tool - available to all skills requiredScopes: ["project:read"], description: [ "Find projects in Sentry.", diff --git a/packages/mcp-server/src/tools/find-releases.ts b/packages/mcp-server/src/tools/find-releases.ts index 7f3b12ffd..e610746ef 100644 --- a/packages/mcp-server/src/tools/find-releases.ts +++ b/packages/mcp-server/src/tools/find-releases.ts @@ -11,6 +11,7 @@ import { export default defineTool({ name: "find_releases", + requiredSkills: ["inspect"], // Only available in inspect skill requiredScopes: ["project:read"], description: [ "Find releases in Sentry.", diff --git a/packages/mcp-server/src/tools/find-teams.ts b/packages/mcp-server/src/tools/find-teams.ts index 591db1361..37e03aa78 100644 --- a/packages/mcp-server/src/tools/find-teams.ts +++ b/packages/mcp-server/src/tools/find-teams.ts @@ -13,6 +13,7 @@ const RESULT_LIMIT = 25; export default defineTool({ name: "find_teams", + requiredSkills: ["inspect", "triage", "project-management"], // Team viewing and management requiredScopes: ["team:read"], description: [ "Find teams in an organization in Sentry.", diff --git a/packages/mcp-server/src/tools/get-doc.ts b/packages/mcp-server/src/tools/get-doc.ts index f70cd2894..e409d8fb2 100644 --- a/packages/mcp-server/src/tools/get-doc.ts +++ b/packages/mcp-server/src/tools/get-doc.ts @@ -9,7 +9,8 @@ import { USER_AGENT } from "../version"; export default defineTool({ name: "get_doc", - requiredScopes: ["docs"], // Documentation reading requires docs permission + requiredSkills: ["docs"], // Only available in docs skill + requiredScopes: [], // No Sentry API scopes required - authorization via 'docs' skill description: [ "Fetch the full markdown content of a Sentry documentation page.", "", diff --git a/packages/mcp-server/src/tools/get-event-attachment.ts b/packages/mcp-server/src/tools/get-event-attachment.ts index afece3966..b4a23bcba 100644 --- a/packages/mcp-server/src/tools/get-event-attachment.ts +++ b/packages/mcp-server/src/tools/get-event-attachment.ts @@ -17,6 +17,7 @@ import { setTag } from "@sentry/core"; export default defineTool({ name: "get_event_attachment", + requiredSkills: ["inspect"], // Only available in inspect skill requiredScopes: ["event:read"], description: [ "Download attachments from a Sentry event.", diff --git a/packages/mcp-server/src/tools/get-issue-details.ts b/packages/mcp-server/src/tools/get-issue-details.ts index 1d6554908..e49d1b335 100644 --- a/packages/mcp-server/src/tools/get-issue-details.ts +++ b/packages/mcp-server/src/tools/get-issue-details.ts @@ -28,6 +28,7 @@ import { export default defineTool({ name: "get_issue_details", + requiredSkills: ["inspect", "triage", "seer"], // Available in inspect, triage, and seer skills requiredScopes: ["event:read"], description: [ "Get detailed information about a specific Sentry issue by ID.", diff --git a/packages/mcp-server/src/tools/get-trace-details.ts b/packages/mcp-server/src/tools/get-trace-details.ts index 47fcdda16..bc1ca0674 100644 --- a/packages/mcp-server/src/tools/get-trace-details.ts +++ b/packages/mcp-server/src/tools/get-trace-details.ts @@ -13,6 +13,7 @@ const MIN_AVG_DURATION_MS = 5; export default defineTool({ name: "get_trace_details", + requiredSkills: ["inspect"], // Only available in inspect skill requiredScopes: ["event:read"], description: [ "Get detailed information about a specific Sentry trace by ID.", diff --git a/packages/mcp-server/src/tools/search-docs.ts b/packages/mcp-server/src/tools/search-docs.ts index 636d25391..af60a9b5c 100644 --- a/packages/mcp-server/src/tools/search-docs.ts +++ b/packages/mcp-server/src/tools/search-docs.ts @@ -8,7 +8,8 @@ import { ParamSentryGuide } from "../schema"; export default defineTool({ name: "search_docs", - requiredScopes: ["docs"], // Documentation search requires docs permission + requiredSkills: ["docs"], // Only available in docs skill + requiredScopes: [], // No Sentry API scopes required - authorization via 'docs' skill description: [ "Search Sentry documentation for SDK setup, instrumentation, and configuration guidance.", "", diff --git a/packages/mcp-server/src/tools/search-events/handler.ts b/packages/mcp-server/src/tools/search-events/handler.ts index db2aee506..2b2c7454b 100644 --- a/packages/mcp-server/src/tools/search-events/handler.ts +++ b/packages/mcp-server/src/tools/search-events/handler.ts @@ -19,6 +19,7 @@ import { UserInputError } from "../../errors"; export default defineTool({ name: "search_events", + requiredSkills: ["inspect", "triage", "seer"], // Available in inspect, triage, and seer skills requiredScopes: ["event:read"], description: [ "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.", diff --git a/packages/mcp-server/src/tools/search-issues/handler.ts b/packages/mcp-server/src/tools/search-issues/handler.ts index 92acf8ac8..15f05ee29 100644 --- a/packages/mcp-server/src/tools/search-issues/handler.ts +++ b/packages/mcp-server/src/tools/search-issues/handler.ts @@ -10,6 +10,7 @@ import { formatIssueResults, formatExplanation } from "./formatters"; export default defineTool({ name: "search_issues", + requiredSkills: ["inspect", "triage", "seer"], // Available in inspect, triage, and seer skills requiredScopes: ["event:read"], description: [ "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.", diff --git a/packages/mcp-server/src/tools/types.ts b/packages/mcp-server/src/tools/types.ts index 697b09314..02a3dd1f9 100644 --- a/packages/mcp-server/src/tools/types.ts +++ b/packages/mcp-server/src/tools/types.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import type { ServerContext } from "../types"; import type { Scope } from "../permissions"; +import type { Skill } from "../skills"; import type { TextContent, ImageContent, @@ -13,7 +14,8 @@ export interface ToolConfig< name: string; description: string; inputSchema: TSchema; - requiredScopes: Scope[]; + requiredSkills: Skill[]; // NEW: Which skills enable this tool + requiredScopes: Scope[]; // LEGACY: Which API scopes needed (deprecated, for backward compatibility) annotations: { readOnlyHint?: boolean; destructiveHint?: boolean; diff --git a/packages/mcp-server/src/tools/update-issue.ts b/packages/mcp-server/src/tools/update-issue.ts index e5e6ed798..09a045cef 100644 --- a/packages/mcp-server/src/tools/update-issue.ts +++ b/packages/mcp-server/src/tools/update-issue.ts @@ -16,6 +16,7 @@ import { export default defineTool({ name: "update_issue", + requiredSkills: ["triage"], // Only available in triage skill requiredScopes: ["event:write"], description: [ "Update an issue's status or assignment in Sentry. This allows you to resolve, ignore, or reassign issues.", diff --git a/packages/mcp-server/src/tools/update-project.ts b/packages/mcp-server/src/tools/update-project.ts index 11093f9fb..8b690a7bb 100644 --- a/packages/mcp-server/src/tools/update-project.ts +++ b/packages/mcp-server/src/tools/update-project.ts @@ -16,6 +16,7 @@ import { export default defineTool({ name: "update_project", + requiredSkills: ["project-management"], // Only available in project-management skill requiredScopes: ["project:write"], description: [ "Update project settings in Sentry, such as name, slug, platform, and team assignment.", diff --git a/packages/mcp-server/src/tools/use-sentry/handler.test.ts b/packages/mcp-server/src/tools/use-sentry/handler.test.ts index 7e67322a4..566e75d9f 100644 --- a/packages/mcp-server/src/tools/use-sentry/handler.test.ts +++ b/packages/mcp-server/src/tools/use-sentry/handler.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; import useSentry from "./handler"; import type { ServerContext } from "../../types"; import type { Scope } from "../../permissions"; +import type { Skill } from "../../skills"; // Mock the embedded agent vi.mock("./agent", () => ({ @@ -23,8 +24,15 @@ const ALL_SCOPES: Scope[] = [ "event:read", "event:write", "project:releases", - "seer", +]; + +// Use all skills for testing to ensure all tools are available +const ALL_SKILLS: Skill[] = [ + "inspect", "docs", + "seer", + "triage", + "project-management", ]; const mockContext: ServerContext = { @@ -34,6 +42,7 @@ const mockContext: ServerContext = { clientId: "test-client", constraints: {}, grantedScopes: new Set(ALL_SCOPES), + grantedSkills: new Set(ALL_SKILLS), }; describe("use_sentry handler", () => { diff --git a/packages/mcp-server/src/tools/use-sentry/handler.ts b/packages/mcp-server/src/tools/use-sentry/handler.ts index f1b9b2982..7e5240534 100644 --- a/packages/mcp-server/src/tools/use-sentry/handler.ts +++ b/packages/mcp-server/src/tools/use-sentry/handler.ts @@ -36,6 +36,7 @@ function formatToolCallTrace(toolCalls: ToolCall[]): string { export default defineTool({ name: "use_sentry", + requiredSkills: [], // Not exposed via standard MCP - accessed via agent mode requiredScopes: [], // No specific scopes - uses authentication token description: [ "Use Sentry's MCP Agent to answer questions related to Sentry (sentry.io).", diff --git a/packages/mcp-server/src/tools/use-sentry/tool-wrapper.test.ts b/packages/mcp-server/src/tools/use-sentry/tool-wrapper.test.ts index 18af58865..a2ab7d534 100644 --- a/packages/mcp-server/src/tools/use-sentry/tool-wrapper.test.ts +++ b/packages/mcp-server/src/tools/use-sentry/tool-wrapper.test.ts @@ -17,6 +17,7 @@ const mockTool: ToolConfig<{ projectSlug: z.string().optional(), someParam: z.string(), }, + requiredSkills: [], // Required by ToolConfig requiredScopes: [], annotations: { readOnlyHint: true, @@ -158,6 +159,7 @@ describe("wrapToolForAgent", () => { inputSchema: { param: z.string(), }, + requiredSkills: [], // Required by ToolConfig requiredScopes: [], annotations: { readOnlyHint: true, diff --git a/packages/mcp-server/src/tools/whoami.ts b/packages/mcp-server/src/tools/whoami.ts index 8f3b2d1f9..f91376f00 100644 --- a/packages/mcp-server/src/tools/whoami.ts +++ b/packages/mcp-server/src/tools/whoami.ts @@ -1,6 +1,7 @@ import { defineTool } from "../internal/tool-helpers/define"; import { apiServiceFromContext } from "../internal/tool-helpers/api"; import type { ServerContext } from "../types"; +import { ALL_SKILLS } from "../skills"; export default defineTool({ name: "whoami", @@ -11,6 +12,7 @@ export default defineTool({ "- Get the user's name and email address.", ].join("\n"), inputSchema: {}, + requiredSkills: ALL_SKILLS, // Foundational tool - available to all skills requiredScopes: [], // No specific scopes required - uses authentication token annotations: { readOnlyHint: true, diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts index 24b4844cc..0261ccd66 100644 --- a/packages/mcp-server/src/types.ts +++ b/packages/mcp-server/src/types.ts @@ -6,6 +6,7 @@ * extraction and handler registration. */ import type { Scope } from "./permissions"; +import type { Skill } from "./skills"; /** * Constraints that restrict the MCP session scope @@ -34,7 +35,10 @@ export type ServerContext = { openaiBaseUrl?: string; userId?: string | null; clientId?: string; - // Granted scopes for tool access control + // Granted skills for tool access control (user-facing capabilities, primary authorization method) + grantedSkills?: Set | ReadonlySet; + // Granted scopes derived from skills - for backward compatibility with old MCP clients + // that don't support grantedSkills and only understand grantedScopes grantedScopes?: Set | ReadonlySet; // URL-based session constraints constraints: Constraints;