From 32017abfdc0003c739d201023ad1b122aa83ec1d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 8 Jul 2025 09:55:49 -0700 Subject: [PATCH 1/7] refactor: modularize tools into individual modules with build-time definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split monolithic tools.ts into 19 individual tool modules - Each tool module uses defineTool helper for consistency - Create build-time generation of toolDefinitions.json - Update mcp-cloudflare to use generated definitions - Remove toolDefinitions.ts in favor of build-time generation - Add comprehensive refactoring documentation This refactoring improves maintainability by: - Limiting tool files to ~200 lines for better AI agent context - Using type-safe defineTool helper pattern - Generating client-safe definitions without server dependencies - Following one-tool-per-file architecture 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../mcp-cloudflare/src/client/pages/home.tsx | 8 +- packages/mcp-server/TOOL_REFACTORING_PLAN.md | 377 +++++ packages/mcp-server/package.json | 6 +- .../scripts/generate-tool-definitions.ts | 225 +++ packages/mcp-server/src/server.ts | 174 ++- .../mcp-server/src/toolDefinitions.test.ts | 2 +- packages/mcp-server/src/toolDefinitions.ts | 731 ---------- packages/mcp-server/src/tools.test.ts | 12 +- packages/mcp-server/src/tools.ts | 1237 ----------------- .../src/tools/analyze-issue-with-seer.ts | 227 +++ packages/mcp-server/src/tools/create-dsn.ts | 67 + .../mcp-server/src/tools/create-project.ts | 90 ++ packages/mcp-server/src/tools/create-team.ts | 47 + packages/mcp-server/src/tools/find-dsns.ts | 59 + packages/mcp-server/src/tools/find-errors.ts | 109 ++ packages/mcp-server/src/tools/find-issues.ts | 108 ++ .../src/tools/find-organizations.ts | 57 + .../mcp-server/src/tools/find-projects.ts | 45 + .../mcp-server/src/tools/find-releases.ts | 135 ++ packages/mcp-server/src/tools/find-tags.ts | 40 + packages/mcp-server/src/tools/find-teams.ts | 45 + .../mcp-server/src/tools/find-transactions.ts | 97 ++ packages/mcp-server/src/tools/get-doc.ts | 126 ++ .../mcp-server/src/tools/get-issue-details.ts | 129 ++ packages/mcp-server/src/tools/index.ts | 45 + packages/mcp-server/src/tools/search-docs.ts | 145 ++ packages/mcp-server/src/tools/types.ts | 28 + packages/mcp-server/src/tools/update-issue.ts | 154 ++ .../mcp-server/src/tools/update-project.ts | 156 +++ .../mcp-server/src/tools/utils/api-utils.ts | 51 + .../mcp-server/src/tools/utils/defineTool.ts | 8 + .../mcp-server/src/tools/utils/fetch-utils.ts | 4 + .../src/tools/utils/formatting-utils.ts | 23 + .../mcp-server/src/tools/utils/issue-utils.ts | 10 + .../mcp-server/src/tools/utils/seer-utils.ts | 130 ++ packages/mcp-server/src/tools/whoami.ts | 22 + packages/mcp-server/src/types.ts | 28 - packages/mcp-server/tsdown.config.ts | 2 +- 38 files changed, 2887 insertions(+), 2072 deletions(-) create mode 100644 packages/mcp-server/TOOL_REFACTORING_PLAN.md create mode 100644 packages/mcp-server/scripts/generate-tool-definitions.ts delete mode 100644 packages/mcp-server/src/toolDefinitions.ts delete mode 100644 packages/mcp-server/src/tools.ts create mode 100644 packages/mcp-server/src/tools/analyze-issue-with-seer.ts create mode 100644 packages/mcp-server/src/tools/create-dsn.ts create mode 100644 packages/mcp-server/src/tools/create-project.ts create mode 100644 packages/mcp-server/src/tools/create-team.ts create mode 100644 packages/mcp-server/src/tools/find-dsns.ts create mode 100644 packages/mcp-server/src/tools/find-errors.ts create mode 100644 packages/mcp-server/src/tools/find-issues.ts create mode 100644 packages/mcp-server/src/tools/find-organizations.ts create mode 100644 packages/mcp-server/src/tools/find-projects.ts create mode 100644 packages/mcp-server/src/tools/find-releases.ts create mode 100644 packages/mcp-server/src/tools/find-tags.ts create mode 100644 packages/mcp-server/src/tools/find-teams.ts create mode 100644 packages/mcp-server/src/tools/find-transactions.ts create mode 100644 packages/mcp-server/src/tools/get-doc.ts create mode 100644 packages/mcp-server/src/tools/get-issue-details.ts create mode 100644 packages/mcp-server/src/tools/index.ts create mode 100644 packages/mcp-server/src/tools/search-docs.ts create mode 100644 packages/mcp-server/src/tools/types.ts create mode 100644 packages/mcp-server/src/tools/update-issue.ts create mode 100644 packages/mcp-server/src/tools/update-project.ts create mode 100644 packages/mcp-server/src/tools/utils/api-utils.ts create mode 100644 packages/mcp-server/src/tools/utils/defineTool.ts create mode 100644 packages/mcp-server/src/tools/utils/fetch-utils.ts create mode 100644 packages/mcp-server/src/tools/utils/formatting-utils.ts create mode 100644 packages/mcp-server/src/tools/utils/issue-utils.ts create mode 100644 packages/mcp-server/src/tools/utils/seer-utils.ts create mode 100644 packages/mcp-server/src/tools/whoami.ts diff --git a/packages/mcp-cloudflare/src/client/pages/home.tsx b/packages/mcp-cloudflare/src/client/pages/home.tsx index 232618410..9314f3f0d 100644 --- a/packages/mcp-cloudflare/src/client/pages/home.tsx +++ b/packages/mcp-cloudflare/src/client/pages/home.tsx @@ -1,4 +1,4 @@ -import { TOOL_DEFINITIONS } from "@sentry/mcp-server/toolDefinitions"; +import TOOL_DEFINITIONS from "@sentry/mcp-server/toolDefinitions"; import { RESOURCES } from "@sentry/mcp-server/resources"; import { PROMPT_DEFINITIONS } from "@sentry/mcp-server/promptDefinitions"; import { Link } from "../components/ui/base"; @@ -162,10 +162,10 @@ export default function Home({ onChatClick }: HomeProps) {

{tool.description.split("\n")[0]}

- {tool.paramsSchema ? ( + {tool.inputSchema ? (
- {Object.entries(tool.paramsSchema).map( - ([key, value]) => { + {Object.entries(tool.inputSchema).map( + ([key, value]: [string, any]) => { return (
diff --git a/packages/mcp-server/TOOL_REFACTORING_PLAN.md b/packages/mcp-server/TOOL_REFACTORING_PLAN.md new file mode 100644 index 000000000..1bdd0bb24 --- /dev/null +++ b/packages/mcp-server/TOOL_REFACTORING_PLAN.md @@ -0,0 +1,377 @@ +# Tool Module Refactoring Plan + +## Overview +This document outlines the plan to refactor the monolithic tools module into individual tool modules, improving maintainability and AI agent context management. + +## Key Principles + +1. **NO backwards compatibility** - Clean break from old structure +2. **TOOL_DEFINITIONS export must remain** - Client-side apps (mcp-cloudflare) cannot import server-side code with dependencies like `@sentry/core` +3. **One tool per file** - Maximum ~200 lines for AI agent context management +4. **Use `defineTool` helper** - Consistent pattern with automatic type inference + +## Why TOOL_DEFINITIONS Must Be Separate + +The mcp-cloudflare web application needs to display tool documentation but cannot import server-side code because: +- Server code has Node.js dependencies (`@sentry/core`, `SentryApiService`, etc.) +- Client-side bundlers would fail on these imports +- We need a clean separation between definition metadata and runtime implementation + +## New Directory Structure + +``` +src/ +├── tools/ +│ ├── index.ts # Barrel export +│ ├── whoami.ts +│ ├── find-organizations.ts +│ ├── find-teams.ts +│ ├── find-projects.ts +│ ├── find-issues.ts +│ ├── find-releases.ts +│ ├── find-tags.ts +│ ├── get-issue-details.ts +│ ├── update-issue.ts +│ ├── find-errors.ts +│ ├── find-transactions.ts +│ ├── create-team.ts +│ ├── create-project.ts +│ ├── update-project.ts +│ ├── create-dsn.ts +│ ├── find-dsns.ts +│ ├── analyze-issue-with-seer.ts +│ ├── search-docs.ts +│ ├── get-doc.ts +│ └── utils/ +│ ├── api-service.ts # apiServiceFromContext function +│ ├── formatting.ts # formatAssignedTo, formatIssueOutput, etc. +│ └── seer-helpers.ts # Seer-specific helpers +``` + +## Type System Changes + +### Updated Types +```typescript +// No need for a Tool interface - we'll use type inference from defineTool +``` + +### Removed Types +- `Tool` - No longer needed, using inference instead +- `ToolName` - Now exported from tools/index.ts +- `ToolParams` - No longer needed +- `ToolHandler` - No longer needed +- `ToolHandlerExtended` - No longer needed +- `ToolHandlers` - No longer needed + +## Tool Module Pattern + +We use a `defineTool` helper function for consistency and type safety. + +### Benefits of defineTool + +1. **Type Safety**: Automatic param type inference without boilerplate +2. **Consistency**: All tools follow the same pattern +3. **Future-proofing**: Easy to add features like: + - Automatic telemetry + - Parameter validation + - Tool versioning + - Middleware support +4. **Refactoring**: Changes to tool structure only require updating one function + +### Define Tool Helper + +```typescript +// tools/utils/define-tool.ts +import { z } from 'zod'; +import type { ServerContext } from '../../types'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { Notification } from '@modelcontextprotocol/sdk/types.js'; + +export function defineTool< + TName extends string, + TSchema extends Record +>(config: { + name: TName; + description: string; + paramsSchema: TSchema; + handler: ( + context: ServerContext, + params: z.infer>, + extra: RequestHandlerExtra + ) => Promise; +}) { + return config; +} + +// The return type is fully inferred, preserving the literal name type +// and the exact schema structure +``` + +### Tool Implementation Pattern + +```typescript +// tools/[tool-name].ts +import { defineTool } from './utils/define-tool'; +import { ParamOrganizationSlug, ParamRegionUrl } from '../schema'; +// Import other utilities as needed + +// Define params as plain object with Zod schemas +const paramsSchema = { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + // ... other parameters +}; + +export default defineTool({ + name: "[tool_name]" as const, + description: [ + // Multi-line description + ].join("\n"), + paramsSchema, + handler: async (context, params, extra) => { + // Implementation + // params is automatically typed based on paramsSchema + } +}); +``` + +## Barrel Export Structure + +```typescript +// tools/index.ts +import whoami from './whoami'; +import findOrganizations from './find-organizations'; +// ... all 19 tools + +// Default export: object mapping tool names to tools +export default { + whoami, + find_organizations: findOrganizations, + find_teams: findTeams, + // ... all 19 tools mapped by name +} as const; + +// Type export +export type ToolName = keyof typeof import('./index').default; +``` + +## toolDefinitions Strategy - Build-time Generation + +**There will be NO toolDefinitions export from the TypeScript code.** Instead, we will generate a static JSON file at build time. + +### Implementation +Create a build script that extracts definitions from tools and generates `toolDefinitions.json`: + +```typescript +// scripts/generate-tool-definitions.ts +import * as fs from 'fs'; +import tools from '../src/tools'; + +const definitions = Object.entries(tools).map(([key, tool]) => ({ + name: tool.name, + description: tool.description, + paramsSchema: extractSchemaDescriptions(tool.paramsSchema) +})); + +function extractSchemaDescriptions(schema: Record): Record { + if (!schema || typeof schema !== 'object') { + return {}; + } + + return Object.fromEntries( + Object.entries(schema).map(([key, zodSchema]) => { + // Extract description from the Zod schema + let description = ''; + + if (zodSchema && typeof zodSchema === 'object') { + // Type assertion for Zod schema shape + const schemaObj = zodSchema as { description?: string; _def?: { innerType?: { description?: string } } }; + + // Zod stores description directly on the schema object + description = schemaObj.description || ''; + + // For optional schemas, we might need to check the wrapped schema + if (!description && schemaObj._def?.innerType) { + description = schemaObj._def.innerType.description || ''; + } + } + + return [key, { description }]; + }) + ); +} + +fs.writeFileSync('./dist/toolDefinitions.json', JSON.stringify(definitions, null, 2)); +``` + +### Build Process Integration +Add to package.json scripts: +```json +{ + "scripts": { + "build": "tsdown && npm run generate-tool-definitions", + "generate-tool-definitions": "tsx scripts/generate-tool-definitions.ts" + } +} +``` + +### Client Usage (mcp-cloudflare) +The mcp-cloudflare app will import the generated JSON file: +```typescript +// Instead of: import { TOOL_DEFINITIONS } from "@sentry/mcp-server/toolDefinitions"; +import TOOL_DEFINITIONS from "@sentry/mcp-server/dist/toolDefinitions.json"; +``` + +This approach ensures: +- Complete separation of server code from client-importable definitions +- No risk of bundling server dependencies into client code +- Automatic synchronization with actual tool implementations +- Type safety during build process + +## Migration Checklist + +### Phase 1: Setup +- [x] Create `src/tools/` directory +- [x] Create `src/tools/utils/` directory +- [x] Create tool types in `tools/types.ts` + +### Phase 2: Utilities +- [x] Create `tools/utils/defineTool.ts` with the defineTool helper +- [x] Extract `apiServiceFromContext` to `tools/utils/api-utils.ts` +- [x] Extract formatting helpers to `tools/utils/formatting-utils.ts` +- [x] Extract Seer helpers to `tools/utils/seer-utils.ts` + +### Phase 3: Tool Migration (19 tools) +1. [x] whoami +2. [x] find_organizations +3. [x] find_teams +4. [x] find_projects +5. [x] find_issues +6. [x] find_releases +7. [x] find_tags +8. [x] get_issue_details +9. [x] update_issue +10. [x] find_errors +11. [x] find_transactions +12. [x] create_team +13. [x] create_project +14. [x] update_project +15. [x] create_dsn +16. [x] find_dsns +17. [x] analyze_issue_with_seer +18. [x] search_docs +19. [x] get_doc + +### Phase 4: Integration +- [x] Create `tools/index.ts` barrel export +- [x] Update `server.ts` to use new default import +- [x] Create `scripts/generate-tool-definitions.ts` +- [x] Update package.json build scripts +- [x] Generate initial `toolDefinitions.js` +- [ ] Update mcp-cloudflare to import from JS file +- [x] Test all tools work correctly +- [x] Update tests (tools.test.ts, toolDefinitions.test.ts) +- [x] Delete `tools.ts` +- [x] Delete `toolDefinitions.ts` +- [x] Remove `/toolDefinitions` export from package.json +- [x] Run full test suite + +## Server.ts Updates + +```typescript +// Remove old imports +- import { TOOL_HANDLERS } from "./tools"; +- import { TOOL_DEFINITIONS } from "./toolDefinitions"; + +// Add new import ++ import tools from "./tools"; + +// Update registration loop +for (const tool of Object.values(tools)) { + server.tool( + tool.name, + tool.description, + tool.paramsSchema, + async (params, extra) => { + // Existing telemetry wrapper + const output = await tool.handler(context, params, extra); + // Existing error handling + } + ); +} +``` + +## Success Criteria + +1. All 19 tools migrated to individual files +2. Each file under 200 lines +3. All tests passing +4. toolDefinitions.json generated and importable by mcp-cloudflare +5. Type safety maintained throughout +6. No runtime changes in behavior + +## Notes on Implementation + +### Zod Schema Shape Extraction +The `tool.paramsSchema.shape` property may not exist on all Zod schemas. The build script should handle: +- `z.object()` schemas (has `.shape`) +- Other Zod types that may not have `.shape` +- Optional parameters and their descriptions + +### File Naming Convention +- Use kebab-case for file names (e.g., `find-organizations.ts`) +- Tool names in code remain snake_case (e.g., `find_organizations`) + +### Import Paths +- Utilities should be imported as `'./utils/api-service'` from tool files +- Type imports should use `type` keyword: `import type { Tool } from '../types'` + +### MCP Registration Details +Based on research, the MCP server expects: +- `paramsSchema` to be a plain object with Zod schemas as values (not a single z.object()) +- The SDK handles parameter validation internally +- Empty schemas should be passed as `{}` not `undefined` +- The MCP SDK accepts Zod schemas directly (no JSON Schema conversion needed) + +Example paramsSchema format: +```typescript +const paramsSchema = { + organizationSlug: ParamOrganizationSlug, // This is a Zod schema + regionUrl: ParamRegionUrl.optional(), // This is also a Zod schema +} +``` + +NOT: +```typescript +const paramsSchema = z.object({ // Don't wrap in z.object() + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), +}) +``` + +### Tools with No Parameters +Some tools like `whoami` and `find_organizations` have no parameters: +```typescript +import { defineTool } from './utils/define-tool'; + +const paramsSchema = {}; // Empty object + +export default defineTool({ + name: "whoami" as const, + description: "...", + paramsSchema, + handler: async (context, params, extra) => { + // params will be typed as {} + return "..."; + } +}); +``` + +### Comments in Empty Schemas +The current codebase includes important comments in empty schemas: +```typescript +paramsSchema: { + // No regionUrl parameter - user data must always come from the main API server +} +``` +These comments should be preserved as they document important API constraints. \ No newline at end of file diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 403c8dce7..5e0e42a85 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -63,7 +63,6 @@ "default": "./dist/resources.js" }, "./toolDefinitions": { - "types": "./dist/toolDefinitions.ts", "default": "./dist/toolDefinitions.js" }, "./types": { @@ -76,14 +75,15 @@ } }, "scripts": { - "build": "tsdown", + "build": "tsdown && pnpm run generate-tool-definitions", "dev": "tsc -w", "start": "tsx src/index.ts", "prepare": "pnpm run build", "test": "vitest", "test:ci": "vitest run --coverage --reporter=junit --outputFile=tests.junit.xml", "tsc": "tsc --noEmit", - "test:watch": "vitest watch" + "test:watch": "vitest watch", + "generate-tool-definitions": "tsx scripts/generate-tool-definitions.ts" }, "devDependencies": { "@sentry/mcp-server-mocks": "workspace:*", diff --git a/packages/mcp-server/scripts/generate-tool-definitions.ts b/packages/mcp-server/scripts/generate-tool-definitions.ts new file mode 100644 index 000000000..1f4273830 --- /dev/null +++ b/packages/mcp-server/scripts/generate-tool-definitions.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env tsx +/** + * Generate tool definitions JSON file for client consumption. + * + * This script imports all tools from src/tools/index and extracts their + * name, description, and parameter schema descriptions to generate a + * toolDefinitions.json file in the dist directory. + * + * This file is used by the mcp-cloudflare client to display tool documentation + * without importing server-side code that has Node.js dependencies. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { z } from "zod"; + +// Get the directory of this script +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Import tools from the source directory +const tools = await import("../src/tools/index.js"); + +interface ToolDefinition { + name: string; + description: string; + inputSchema: Record< + string, + { + description: string; + required: boolean; + type?: string; + } + >; +} + +/** + * Extract schema descriptions from Zod schemas. + * + * This function recursively extracts description metadata from Zod schemas, + * handling optional schemas and nested types. + */ +function extractSchemaDescriptions( + schema: Record, +): Record { + if (!schema || typeof schema !== "object") { + return {}; + } + + return Object.fromEntries( + Object.entries(schema).map(([key, zodSchema]) => { + let description = ""; + let required = true; + let type: string | undefined; + + if (zodSchema && typeof zodSchema === "object") { + // Type assertion for Zod schema shape + const schemaObj = zodSchema as { + description?: string; + _def?: { + innerType?: { + description?: string; + typeName?: string; + }; + typeName?: string; + }; + isOptional?: () => boolean; + }; + + // Extract description from the schema + description = schemaObj.description || ""; + + // Check if this is an optional schema + if (schemaObj._def) { + // For optional schemas, check the inner type + if (schemaObj._def.innerType) { + description = + description || schemaObj._def.innerType.description || ""; + required = false; + type = getZodTypeName(schemaObj._def.innerType.typeName); + } else { + type = getZodTypeName(schemaObj._def.typeName); + } + } + + // Some schemas might have isOptional method + if (typeof schemaObj.isOptional === "function") { + try { + required = !schemaObj.isOptional(); + } catch { + // If isOptional throws, assume required + required = true; + } + } + } + + return [ + key, + { + description, + required, + ...(type && { type }), + }, + ]; + }), + ); +} + +/** + * Convert Zod type names to more readable type names. + */ +function getZodTypeName(typeName?: string): string | undefined { + if (!typeName) return undefined; + + switch (typeName) { + case "ZodString": + return "string"; + case "ZodNumber": + return "number"; + case "ZodBoolean": + return "boolean"; + case "ZodArray": + return "array"; + case "ZodObject": + return "object"; + case "ZodEnum": + return "enum"; + case "ZodUnion": + return "union"; + case "ZodLiteral": + return "literal"; + default: + return typeName; + } +} + +/** + * Generate tool definitions from imported tools. + */ +function generateToolDefinitions(): ToolDefinition[] { + const toolsDefault = tools.default; + + if (!toolsDefault || typeof toolsDefault !== "object") { + throw new Error("Failed to import tools from src/tools/index.js"); + } + + return Object.entries(toolsDefault).map(([key, tool]) => { + if (!tool || typeof tool !== "object") { + throw new Error(`Invalid tool: ${key}`); + } + + const toolObj = tool as { + name: string; + description: string; + inputSchema: Record; + }; + + if (!toolObj.name || !toolObj.description) { + throw new Error(`Tool ${key} is missing name or description`); + } + + return { + name: toolObj.name, + description: toolObj.description, + inputSchema: extractSchemaDescriptions(toolObj.inputSchema || {}), + }; + }); +} + +/** + * Main function to generate and write tool definitions. + */ +async function main() { + try { + console.log("Generating tool definitions..."); + + const definitions = generateToolDefinitions(); + + // Ensure dist directory exists + const distDir = path.join(__dirname, "../dist"); + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); + } + + // Write the definitions to JavaScript file + const outputPath = path.join(distDir, "toolDefinitions.js"); + const jsContent = `export default ${JSON.stringify(definitions, null, 2)};`; + fs.writeFileSync(outputPath, jsContent); + + // Write TypeScript declaration file + const dtsPath = path.join(distDir, "toolDefinitions.d.ts"); + const dtsContent = `declare const toolDefinitions: Array<{ + name: string; + description: string; + inputSchema: Record; +}>; +export default toolDefinitions;`; + fs.writeFileSync(dtsPath, dtsContent); + + console.log( + `✅ Generated tool definitions for ${definitions.length} tools`, + ); + console.log(`📄 Output: ${outputPath}`); + + // Log summary of tools + console.log("\nTools included:"); + definitions.forEach((def, index) => { + const paramCount = Object.keys(def.inputSchema).length; + console.log(` ${index + 1}. ${def.name} (${paramCount} parameters)`); + }); + } catch (error) { + console.error("❌ Failed to generate tool definitions:", error); + process.exit(1); + } +} + +// Run the script +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index cad3d25e3..04e4219e3 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -23,8 +23,7 @@ import type { ServerRequest, ServerNotification, } from "@modelcontextprotocol/sdk/types.js"; -import { TOOL_HANDLERS } from "./tools"; -import { TOOL_DEFINITIONS } from "./toolDefinitions"; +import tools from "./tools/index"; import type { ServerContext } from "./types"; import { setTag, setUser, startNewTrace, startSpan } from "@sentry/core"; import { logError } from "./logging"; @@ -148,53 +147,105 @@ export async function configureServer({ }; for (const resource of RESOURCES) { - // Create the handler function once - it's the same for both resource types - const resourceHandler = async ( - url: URL, - extra: RequestHandlerExtra, - ) => { - return await startNewTrace(async () => { - return await startSpan( - { - name: `resources/read ${url.toString()}`, - attributes: { - "mcp.resource.name": resource.name, - "mcp.resource.uri": url.toString(), - ...(context.userAgent && { - "user_agent.original": context.userAgent, - }), + // TODO: this doesnt support any error handling afaict via the spec + if (isTemplateResource(resource)) { + // Template resource handler receives variables instead of RequestHandlerExtra + const templateHandler = async ( + url: URL, + variables: Record, + ) => { + return await startNewTrace(async () => { + return await startSpan( + { + name: `resources/read ${url.toString()}`, + attributes: { + "mcp.resource.name": resource.name, + "mcp.resource.uri": url.toString(), + ...(context.userAgent && { + "user_agent.original": context.userAgent, + }), + }, }, - }, - async () => { - if (context.userId) { - setUser({ - id: context.userId, - }); - } - if (context.clientId) { - setTag("client.id", context.clientId); - } + async () => { + if (context.userId) { + setUser({ + id: context.userId, + }); + } + if (context.clientId) { + setTag("client.id", context.clientId); + } - return resource.handler(url, extra); - }, - ); - }); - }; + // Create a minimal RequestHandlerExtra for the handler + const extra: RequestHandlerExtra< + ServerRequest, + ServerNotification + > = { + signal: new AbortController().signal, + requestId: crypto.randomUUID(), + sendNotification: async () => {}, + sendRequest: async () => ({}) as any, + }; - // TODO: this doesnt support any error handling afaict via the spec - const source = isTemplateResource(resource) - ? resource.template - : resource.uri; + return resource.handler(url, extra); + }, + ); + }); + }; - server.registerResource( - resource.name, - source, - { - description: resource.description, - mimeType: resource.mimeType, - }, - resourceHandler, - ); + server.registerResource( + resource.name, + resource.template, + { + description: resource.description, + mimeType: resource.mimeType, + }, + templateHandler, + ); + } else { + // Regular resource handler + const resourceHandler = async ( + url: URL, + extra: RequestHandlerExtra, + ) => { + return await startNewTrace(async () => { + return await startSpan( + { + name: `resources/read ${url.toString()}`, + attributes: { + "mcp.resource.name": resource.name, + "mcp.resource.uri": url.toString(), + ...(context.userAgent && { + "user_agent.original": context.userAgent, + }), + }, + }, + async () => { + if (context.userId) { + setUser({ + id: context.userId, + }); + } + if (context.clientId) { + setTag("client.id", context.clientId); + } + + return resource.handler(url, extra); + }, + ); + }); + }; + + server.registerResource( + resource.name, + resource.uri, + { + description: resource.description, + mimeType: resource.mimeType, + }, + resourceHandler, + ); + } } for (const prompt of PROMPT_DEFINITIONS) { @@ -261,14 +312,15 @@ export async function configureServer({ ); } - for (const tool of TOOL_DEFINITIONS) { - const handler = TOOL_HANDLERS[tool.name]; - + for (const [toolKey, tool] of Object.entries(tools)) { server.tool( - tool.name as string, + tool.name, tool.description, - tool.paramsSchema ? tool.paramsSchema : {}, - async (...args) => { + tool.inputSchema, + async ( + params: any, + extra: RequestHandlerExtra, + ) => { try { return await startNewTrace(async () => { return await startSpan( @@ -279,7 +331,7 @@ export async function configureServer({ ...(context.userAgent && { "user_agent.original": context.userAgent, }), - ...extractMcpParameters(args[0] || {}), + ...extractMcpParameters(params || {}), }, }, async (span) => { @@ -293,17 +345,15 @@ export async function configureServer({ } try { - // TODO(dcramer): I'm too dumb to figure this out - // @ts-ignore - const output = await handler(context, ...args); + const output = await tool.handler(params, context); span.setStatus({ code: 1, // ok }); return { content: [ { - type: "text", - text: output, + type: "text" as const, + text: String(output), }, ], }; @@ -311,15 +361,7 @@ export async function configureServer({ span.setStatus({ code: 2, // error }); - return { - content: [ - { - type: "text", - text: await logAndFormatError(error), - }, - ], - isError: true, - }; + throw error; } }, ); diff --git a/packages/mcp-server/src/toolDefinitions.test.ts b/packages/mcp-server/src/toolDefinitions.test.ts index b79cde90f..c2ba494d5 100644 --- a/packages/mcp-server/src/toolDefinitions.test.ts +++ b/packages/mcp-server/src/toolDefinitions.test.ts @@ -1,5 +1,5 @@ import { assert, test } from "vitest"; -import { TOOL_DEFINITIONS } from "./toolDefinitions"; +import TOOL_DEFINITIONS from "../dist/toolDefinitions.js"; // VSCode (via OpenAI) limits to 1024 characters, but its tough to hit that right now, // so instead lets limit the blast damage and hope that e.g. OpenAI will increase the limit. diff --git a/packages/mcp-server/src/toolDefinitions.ts b/packages/mcp-server/src/toolDefinitions.ts deleted file mode 100644 index f2eae2a68..000000000 --- a/packages/mcp-server/src/toolDefinitions.ts +++ /dev/null @@ -1,731 +0,0 @@ -/** - * Tool definitions for the Sentry MCP server. - * - * Declarative definitions for all MCP tools that interface with Sentry's API. - * Each tool definition includes its name, description, parameter schema, and - * documentation for LLM consumption. - * - * @example Tool Definition Structure - * ```typescript - * { - * name: "tool_name" as const, - * description: [ - * "Brief tool description.", - * "", - * "Use this tool when you need to:", - * "- Specific use case 1", - * "- Specific use case 2", - * ].join("\n"), - * paramsSchema: { - * organizationSlug: ParamOrganizationSlug, - * optionalParam: ParamSchema.optional(), - * }, - * } - * ``` - */ - -// TODO: this gets imported by the client code and thus is separated from server code -// to avoid bundling issues. We'd like to find a better solution that isnt so brittle and keeps this code co-located w/ its tool calls. -import { - ParamOrganizationSlug, - ParamIssueShortId, - ParamPlatform, - ParamProjectSlug, - ParamQuery, - ParamSentryGuide, - ParamTransaction, - ParamRegionUrl, - ParamProjectSlugOrAll, - ParamIssueUrl, - ParamTeamSlug, - ParamIssueStatus, - ParamAssignedTo, -} from "./schema"; -import { z } from "zod"; - -/** - * All MCP tool definitions for the Sentry server. - * - * Used by server.ts to register tools with the MCP server and by tools.ts - * to validate parameters. Each definition includes name, description, and - * Zod parameter schema. - */ -export const TOOL_DEFINITIONS = [ - { - name: "whoami" as const, - description: [ - "Identify the authenticated user in Sentry.", - "", - "Use this tool when you need to:", - "- Get the user's name and email address.", - ].join("\n"), - paramsSchema: { - // No regionUrl parameter - user data must always come from the main API server - }, - }, - { - name: "find_organizations" as const, - description: [ - "Find organizations that the user has access to in Sentry.", - "", - "Use this tool when you need to:", - "- View all organizations in Sentry", - "- Find an organization's slug to aid other tool requests", - ].join("\n"), - paramsSchema: { - // No regionUrl parameter - user data and region lists must always come from the main API server - }, - }, - { - name: "find_teams" as const, - description: [ - "Find teams in an organization in Sentry.", - "", - "Use this tool when you need to:", - "- View all teams in a Sentry organization", - "- Find a team's slug to aid other tool requests", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - }, - }, - { - name: "find_projects" as const, - description: [ - "Find projects in Sentry.", - "", - "Use this tool when you need to:", - "- View all projects in a Sentry organization", - "- Find a project's slug to aid other tool requests", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - }, - }, - { - name: "find_issues" as const, - description: [ - "Find issues in Sentry.", - "", - "Use this tool when you need to:", - "- View all issues in a Sentry organization", - "", - "If you're looking for more granular data beyond a summary of identified problems, you should use the `find_errors()` or `find_transactions()` tools instead.", - "", - "", - "### Find the newest unresolved issues across 'my-organization'", - "", - "```", - "find_issues(organizationSlug='my-organization', query='is:unresolved', sortBy='last_seen')", - "```", - "", - "### Find the most frequently occurring crashes in the 'my-project' project", - "", - "```", - "find_issues(organizationSlug='my-organization', projectSlug='my-project', query='is:unresolved error.handled:false', sortBy='count')", - "```", - "", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", - "- You can use the `find_tags()` tool to see what user-defined tags are available.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - projectSlug: ParamProjectSlug.optional(), - query: ParamQuery.optional(), - sortBy: z - .enum(["last_seen", "first_seen", "count", "userCount"]) - .describe( - "Sort the results either by the last time they occurred, the first time they occurred, the count of occurrences, or the number of users affected.", - ) - .optional(), - }, - }, - { - name: "find_releases" as const, - description: [ - "Find releases in Sentry.", - "", - "Use this tool when you need to:", - "- Find recent releases in a Sentry organization", - "- Find the most recent version released of a specific project", - "- Determine when a release was deployed to an environment", - "", - "", - "### Find the most recent releases in the 'my-organization' organization", - "", - "```", - "find_releases(organizationSlug='my-organization')", - "```", - "", - "### Find releases matching '2ce6a27' in the 'my-organization' organization", - "", - "```", - "find_releases(organizationSlug='my-organization', query='2ce6a27')", - "```", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - projectSlug: ParamProjectSlugOrAll.optional(), - query: z - .string() - .trim() - .describe("Search for versions which contain the provided string.") - .optional(), - }, - }, - { - name: "find_tags" as const, - description: [ - "Find tags in Sentry.", - "", - "Use this tool when you need to:", - "- Find tags available to use in search queries (such as `find_issues()` or `find_errors()`)", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - }, - }, - { - name: "get_issue_details" as const, - description: [ - "Retrieve issue details from Sentry for a specific Issue ID, including the stacktrace and error message if available. Either issueId or issueUrl MUST be provided.", - "", - "**NOTE: If the user asks HOW TO FIX an issue, use `analyze_issue_with_seer` instead!**", - "", - "Use this tool when you need to:", - "- View error details, stacktraces, and metadata", - "- Investigate when/where an error occurred", - "- Access raw error information from Sentry", - "", - "Do NOT use this tool when:", - '- User asks "how do I fix this?" → Use `analyze_issue_with_seer`', - "- User wants root cause analysis → Use `analyze_issue_with_seer`", - "- User needs code fixes → Use `analyze_issue_with_seer`", - "", - "", - "### Get details for issue ID 'CLOUDFLARE-MCP-41'", - "", - "```", - "get_issue_details(organizationSlug='my-organization', issueId='CLOUDFLARE-MCP-41')", - "```", - "", - "### Get details for event ID 'c49541c747cb4d8aa3efb70ca5aba243'", - "", - "```", - "get_issue_details(organizationSlug='my-organization', eventId='c49541c747cb4d8aa3efb70ca5aba243')", - "```", - "", - - "", - "- If the user provides the `issueUrl`, you can ignore the other parameters.", - "- If the user provides `issueId` or `eventId` (only one is needed), `organizationSlug` is required.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug.optional(), - regionUrl: ParamRegionUrl.optional(), - issueId: ParamIssueShortId.optional(), - eventId: z.string().trim().describe("The ID of the event.").optional(), - issueUrl: ParamIssueUrl.optional(), - }, - }, - { - name: "update_issue" as const, - description: [ - "Update an issue's status or assignment in Sentry. This allows you to resolve, ignore, or reassign issues.", - "", - "Use this tool when you need to:", - "- Resolve an issue that has been fixed", - "- Assign an issue to a team member or team for investigation", - "- Mark an issue as ignored to reduce noise", - "- Reopen a resolved issue by setting status to 'unresolved'", - "", - "", - "### Resolve an issue", - "", - "```", - "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='resolved')", - "```", - "", - "### Assign an issue to a user", - "", - "```", - "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', assignedTo='john.doe')", - "```", - "", - "### Resolve an issue and assign it to yourself", - "", - "```", - "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='resolved', assignedTo='me')", - "```", - "", - "### Mark an issue as ignored", - "", - "```", - "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='ignored')", - "```", - "", - "", - "", - "", - "- If the user provides the `issueUrl`, you can ignore the other required parameters and extract them from the URL.", - "- At least one of `status` or `assignedTo` must be provided to update the issue.", - "- Use 'me' as the value for `assignedTo` to assign the issue to the authenticated user.", - "- Valid status values are: 'resolved', 'resolvedInNextRelease', 'unresolved', 'ignored'.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug.optional(), - regionUrl: ParamRegionUrl.optional(), - issueId: ParamIssueShortId.optional(), - issueUrl: ParamIssueUrl.optional(), - status: ParamIssueStatus.optional(), - assignedTo: ParamAssignedTo.optional(), - }, - }, - { - name: "find_errors" as const, - description: [ - "Find errors in Sentry using advanced search syntax.", - "", - "Use this tool when you need to:", - "- Search for production errors in a specific file.", - "- Analyze error patterns and frequencies.", - "- Find recent or frequently occurring errors.", - "", - "", - "### Find common errors within a file", - "", - "To find common errors within a file, you can use the `filename` parameter. This is a suffix based search, so only using the filename or the direct parent folder of the file. The parent folder is preferred when the filename is in a subfolder or a common filename. If you provide generic filenames like `index.js` you're going to end up finding errors that are might be from completely different projects.", - "", - "```", - "find_errors(organizationSlug='my-organization', filename='index.js', sortBy='count')", - "```", - "", - "### Find recent crashes from the 'peated' project", - "", - "```", - "find_errors(organizationSlug='my-organization', query='is:unresolved error.handled:false', projectSlug='peated', sortBy='last_seen')", - "```", - "", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", - "- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you should call `find_organizations()` first.", - "- If you are looking for issues, in a way that you might be looking for something like 'unresolved errors', you should use the `find_issues()` tool", - "- You can use the `find_tags()` tool to see what user-defined tags are available.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - projectSlug: ParamProjectSlugOrAll.optional(), - filename: z - .string() - .trim() - .describe("The filename to search for errors in.") - .optional(), - transaction: ParamTransaction.optional(), - query: ParamQuery.optional(), - sortBy: z - .enum(["last_seen", "count"]) - .optional() - .default("last_seen") - .describe( - "Sort the results either by the last time they occurred or the count of occurrences.", - ), - }, - }, - { - name: "find_transactions" as const, - description: [ - "Find transactions in Sentry using advanced search syntax.", - "", - "Transactions are segments of traces that are associated with a specific route or endpoint.", - "", - "Use this tool when you need to:", - "- Search for production transaction data to understand performance.", - "- Analyze traces and latency patterns.", - "- Find examples of recent requests to endpoints.", - "", - "", - "### Find slow requests to a route", - "", - "...", - "", - "```", - "find_transactions(organizationSlug='my-organization', transaction='/checkout', sortBy='duration')", - "```", - "", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", - "- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you might want to call `find_organizations()` first.", - "- You can use the `find_tags()` tool to see what user-defined tags are available.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - projectSlug: ParamProjectSlugOrAll.optional(), - transaction: ParamTransaction.optional(), - query: ParamQuery.optional(), - sortBy: z - .enum(["timestamp", "duration"]) - .optional() - .default("timestamp") - .describe( - "Sort the results either by the timestamp of the request (most recent first) or the duration of the request (longest first).", - ), - }, - }, - { - name: "create_team" as const, - description: [ - "Create a new team in Sentry.", - "", - "Be careful when using this tool!", - "", - "Use this tool when you need to:", - "- Create a new team in a Sentry organization", - "", - "", - "- If any parameter is ambiguous, you should clarify with the user what they meant.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - name: z.string().trim().describe("The name of the team to create."), - }, - }, - { - name: "create_project" as const, - description: [ - "Create a new project in Sentry, giving you access to a new SENTRY_DSN.", - "", - "Be careful when using this tool!", - "", - "Use this tool when you need to:", - "- Create a new project in a Sentry organization", - "", - "", - "### Create a new javascript project in the 'my-organization' organization", - "", - "```", - "create_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')", - "```", - "", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", - "- If any parameter is ambiguous, you should clarify with the user what they meant.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - teamSlug: ParamTeamSlug, - name: z - .string() - .trim() - .describe( - "The name of the project to create. Typically this is commonly the name of the repository or service. It is only used as a visual label in Sentry.", - ), - platform: ParamPlatform.optional(), - }, - }, - { - name: "create_dsn" as const, - description: [ - "Create a new Sentry DSN for a specific project.", - "", - "Be careful when using this tool!", - "", - "Use this tool when you need to:", - "- Create a new DSN for a specific project", - "", - "", - "### Create a new DSN for the 'my-project' project", - "", - "```", - "create_dsn(organizationSlug='my-organization', projectSlug='my-project', name='Production')", - "```", - "", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", - "- If any parameter is ambiguous, you should clarify with the user what they meant.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - projectSlug: ParamProjectSlug, - name: z - .string() - .trim() - .describe("The name of the DSN to create, for example 'Production'."), - }, - }, - { - name: "update_project" as const, - description: [ - "Update project settings in Sentry, such as name, slug, platform, and team assignment.", - "", - "Be careful when using this tool!", - "", - "Use this tool when you need to:", - "- Update a project's name or slug to fix onboarding mistakes", - "- Change the platform assigned to a project", - "- Update team assignment for a project", - "", - "", - "### Update a project's name and slug", - "", - "```", - "update_project(organizationSlug='my-organization', projectSlug='old-project', name='New Project Name', slug='new-project-slug')", - "```", - "", - "### Assign a project to a different team", - "", - "```", - "update_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='backend-team')", - "```", - "", - "### Update platform", - "", - "```", - "update_project(organizationSlug='my-organization', projectSlug='my-project', platform='python')", - "```", - "", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.", - "- Team assignment is handled separately from other project settings", - "- If any parameter is ambiguous, you should clarify with the user what they meant.", - "- When updating the slug, the project will be accessible at the new slug after the update", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - projectSlug: ParamProjectSlug, - name: z - .string() - .trim() - .describe("The new name for the project") - .optional(), - slug: z - .string() - .toLowerCase() - .trim() - .describe("The new slug for the project (must be unique)") - .optional(), - platform: ParamPlatform.optional(), - teamSlug: ParamTeamSlug.optional().describe( - "The team to assign this project to. Note: this will replace the current team assignment.", - ), - }, - }, - { - name: "find_dsns" as const, - description: [ - "List all Sentry DSNs for a specific project.", - "", - "Use this tool when you need to:", - "- Retrieve a SENTRY_DSN for a specific project", - "", - "", - "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", - "- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you might want to call `find_organizations()` first.", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - projectSlug: ParamProjectSlug, - }, - }, - { - name: "analyze_issue_with_seer" as const, - description: [ - "**ALWAYS use this tool when a user asks how to fix a Sentry issue.** Seer AI analyzes production errors to identify root causes and provide specific code fixes.", - "", - "Use this tool IMMEDIATELY when:", - '- User asks "how do I fix this error?" or "what\'s causing this issue?"', - "- User shares a Sentry issue URL and wants help resolving it", - "- User needs to understand why an error is happening in production", - "- User wants specific code changes to fix their issue", - "- User asks about the root cause of any Sentry error", - "", - "What this tool provides:", - "- Root cause analysis with code-level explanations", - "- Specific file locations and line numbers where errors occur", - "- Concrete code fixes you can apply", - "- Step-by-step implementation guidance", - "", - "This tool automatically:", - "1. Checks if analysis already exists (instant results)", - "2. Starts new AI analysis if needed (~2-5 minutes)", - "3. Returns complete fix recommendations", - "", - "", - '### User: "How do I fix ISSUE-123?"', - "", - "```", - "analyze_issue_with_seer(organizationSlug='my-organization', issueId='ISSUE-123')", - "```", - "", - '### User: "What\'s causing this error? https://my-org.sentry.io/issues/PROJECT-1Z43"', - "", - "```", - "analyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')", - "```", - "", - '### User: "Can you help me understand why this is failing in production?"', - "", - "```", - "analyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')", - "```", - "", - "", - "", - "- ALWAYS prefer this over get_issue_details when users want fixes, not just error details", - "- If the user provides an issueUrl, extract it and use that parameter alone", - "- The analysis includes actual code snippets and fixes, not just error descriptions", - "- Results are cached - subsequent calls return instantly", - "", - ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug.optional(), - regionUrl: ParamRegionUrl.optional(), - issueId: ParamIssueShortId.optional(), - issueUrl: ParamIssueUrl.optional(), - instruction: z - .string() - .describe("Optional custom instruction for the AI analysis") - .optional(), - }, - }, - { - name: "search_docs" as const, - description: [ - "Search Sentry documentation for SDK setup, instrumentation, and configuration guidance.", - "", - "Use this tool when you need to:", - "- Set up Sentry SDK in any language (Python, JavaScript, Go, Ruby, etc.)", - "- Configure specific features like performance monitoring, error sampling, or release tracking", - "- Implement custom instrumentation (spans, transactions, breadcrumbs)", - "- Set up integrations with frameworks (Django, Flask, Express, Next.js, etc.)", - "- Configure data scrubbing, filtering, or sampling rules", - "- Troubleshoot SDK issues or find best practices", - "", - "This tool searches technical documentation, NOT general information about Sentry as a company.", - "", - "", - "### Setting up Sentry in a Python Django app", - "", - "```", - "search_docs(query='Django setup configuration SENTRY_DSN', guide='python/django')", - "```", - "", - "### Setting up source maps for Next.js", - "", - "```", - "search_docs(query='source maps webpack upload', guide='javascript/nextjs')", - "```", - "", - "### Configuring release tracking", - "", - "```", - "search_docs(query='release tracking deployment integration CI/CD')", - "```", - "", - "", - "", - "- Use guide parameter to filter results to specific technologies (e.g., 'javascript' or 'javascript/nextjs')", - "- Include the programming language/framework in your query for SDK-specific results", - "- Use technical terms like 'instrumentation', 'spans', 'transactions' for performance docs", - "- Include specific feature names like 'beforeSend', 'tracesSampleRate', 'SENTRY_DSN'", - "", - ].join("\n"), - paramsSchema: { - query: z - .string() - .trim() - .min( - 2, - "Search query is too short. Please provide at least 2 characters.", - ) - .max( - 200, - "Search query is too long. Please keep your query under 200 characters.", - ) - .describe( - "The search query in natural language. Be specific about what you're looking for.", - ), - maxResults: z - .number() - .int() - .min(1) - .max(10) - .default(3) - .describe("Maximum number of results to return (1-10)") - .optional(), - guide: ParamSentryGuide.optional(), - }, - }, - { - name: "get_doc" as const, - description: [ - "Fetch the full markdown content of a Sentry documentation page.", - "", - "Use this tool when you need to:", - "- Read the complete documentation for a specific topic", - "- Get detailed implementation examples or code snippets", - "- Access the full context of a documentation page", - "- Extract specific sections from documentation", - "", - "", - "### Get the Next.js integration guide", - "", - "```", - "get_doc(path='/platforms/javascript/guides/nextjs.md')", - "```", - "", - "", - "", - "- Use the path from search_docs results for accurate fetching", - "- Paths should end with .md extension", - "", - ].join("\n"), - paramsSchema: { - path: z - .string() - .trim() - .describe( - "The documentation path (e.g., '/platforms/javascript/guides/nextjs.md'). Get this from search_docs results.", - ), - }, - }, -]; diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index 62d4798c9..46de2ad28 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect, vi } from "vitest"; -import { TOOL_HANDLERS } from "./tools"; +import tools from "./tools/index.js"; + +// Create a compatibility wrapper for the old TOOL_HANDLERS structure +const TOOL_HANDLERS = Object.fromEntries( + Object.entries(tools).map(([key, tool]) => [ + key, + async (context: any, params: any) => { + return tool.handler(params, context); + }, + ]), +); describe("whoami", () => { it("serializes", async () => { diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts deleted file mode 100644 index 71f59e704..000000000 --- a/packages/mcp-server/src/tools.ts +++ /dev/null @@ -1,1237 +0,0 @@ -/** - * Tool implementation handlers for the Sentry MCP server. - * - * Contains runtime implementations for all MCP tools defined in `toolDefinitions.ts`. - * Each handler processes tool invocations, validates parameters, calls the Sentry API, - * and returns markdown-formatted responses. - * - * @example Basic Handler Pattern - * ```typescript - * tool_name: async (context, params) => { - * if (!params.organizationSlug) { - * throw new UserInputError("Organization slug is required"); - * } - * - * const apiService = apiServiceFromContext(context, { - * regionUrl: params.regionUrl, - * }); - * setTag("organization.slug", params.organizationSlug); - * - * const results = await apiService.someMethod(params); - * return `# Results\n\n${formatResults(results)}`; - * }, - * ``` - */ -import type { z } from "zod"; -import { - type AutofixRunStepDefaultSchema, - type AutofixRunStepRootCauseAnalysisSchema, - type AutofixRunStepSchema, - type AutofixRunStepSolutionSchema, - type ClientKey, - type Project, - SentryApiService, - type AssignedTo, - ApiError, -} from "./api-client/index"; -import { formatIssueOutput } from "./internal/formatting"; -import { parseIssueParams } from "./internal/issue-helpers"; -import { fetchWithTimeout } from "./internal/fetch-utils"; -import { logError } from "./logging"; -import type { ServerContext, ToolHandlers } from "./types"; -import { setTag } from "@sentry/core"; -import { UserInputError } from "./errors"; - -const SEER_POLLING_INTERVAL = 5000; // 5 seconds -const SEER_TIMEOUT = 5 * 60 * 1000; // 5 minutes - -/** - * Response from the search API endpoint - */ -interface SearchResponse { - query: string; - results: Array<{ - id: string; - url: string; - snippet: string; - relevance: number; - }>; - error?: string; -} - -/** - * Convert autofix status to user-friendly display name - */ -function getStatusDisplayName(status: string): string { - switch (status) { - case "COMPLETED": - return "Complete"; - case "FAILED": - case "ERROR": - return "Failed"; - case "CANCELLED": - return "Cancelled"; - case "NEED_MORE_INFORMATION": - return "Needs More Information"; - case "WAITING_FOR_USER_RESPONSE": - return "Waiting for Response"; - case "PROCESSING": - return "Processing"; - case "IN_PROGRESS": - return "In Progress"; - default: - return status; - } -} - -/** - * Check if an autofix status is terminal (no more updates expected) - */ -function isTerminalStatus(status: string): boolean { - return [ - "COMPLETED", - "FAILED", - "ERROR", - "CANCELLED", - "NEED_MORE_INFORMATION", - "WAITING_FOR_USER_RESPONSE", - ].includes(status); -} - -/** - * Check if an autofix status requires human intervention - */ -function isHumanInterventionStatus(status: string): boolean { - return ( - status === "NEED_MORE_INFORMATION" || status === "WAITING_FOR_USER_RESPONSE" - ); -} - -/** - * Get guidance message for human intervention states - */ -function getHumanInterventionGuidance(status: string): string { - if (status === "NEED_MORE_INFORMATION") { - return "\nSeer needs additional information to continue the analysis. Please review the insights above and consider providing more context.\n"; - } - if (status === "WAITING_FOR_USER_RESPONSE") { - return "\nSeer is waiting for your response to proceed. Please review the analysis and provide feedback.\n"; - } - return ""; -} - -/** - * Creates a SentryApiService instance from server context with optional region override. - * - * For self-hosted Sentry compatibility, empty regionUrl values are ignored gracefully. - * For Sentry's Cloud Service, regionUrl is used to route requests to the correct region. - * - * @param context - Server context containing default host and access token - * @param opts - Options object containing optional regionUrl override - * @returns Configured SentryApiService instance - * @throws {UserInputError} When regionUrl is provided but invalid - */ -function apiServiceFromContext( - context: ServerContext, - opts: { regionUrl?: string } = {}, -) { - let host = context.host; - - if (opts.regionUrl?.trim()) { - try { - const parsedUrl = new URL(opts.regionUrl); - - // Validate that the URL has a proper protocol - if (!["http:", "https:"].includes(parsedUrl.protocol)) { - throw new UserInputError( - `Invalid regionUrl provided: ${opts.regionUrl}. Must include protocol (http:// or https://).`, - ); - } - - // Validate that the host is not just the protocol name - if (parsedUrl.host === "https" || parsedUrl.host === "http") { - throw new UserInputError( - `Invalid regionUrl provided: ${opts.regionUrl}. The host cannot be just a protocol name.`, - ); - } - - host = parsedUrl.host; - } catch (error) { - if (error instanceof UserInputError) { - throw error; - } - throw new UserInputError( - `Invalid regionUrl provided: ${opts.regionUrl}. Must be a valid URL.`, - ); - } - } - - return new SentryApiService({ - host, - accessToken: context.accessToken, - }); -} - -export const TOOL_HANDLERS = { - whoami: async (context, params) => { - // User data endpoints (like /auth/) should never use regionUrl - // as they must always query the main API server, not region-specific servers - const apiService = apiServiceFromContext(context); - const user = await apiService.getAuthenticatedUser(); - return `You are authenticated as ${user.name} (${user.email}).\n\nYour Sentry User ID is ${user.id}.`; - }, - find_organizations: async (context, params) => { - // User data endpoints (like /users/me/regions/) should never use regionUrl - // as they must always query the main API server, not region-specific servers - const apiService = apiServiceFromContext(context); - const organizations = await apiService.listOrganizations(); - - let output = "# Organizations\n\n"; - - if (organizations.length === 0) { - output += "You don't appear to be a member of any organizations.\n"; - return output; - } - - output += organizations - .map((org) => - [ - `## **${org.slug}**`, - "", - `**Web URL:** ${org.links?.organizationUrl || "Not available"}`, - `**Region URL:** ${org.links?.regionUrl || ""}`, - ].join("\n"), - ) - .join("\n\n"); - - output += "\n\n# Using this information\n\n"; - output += `- The organization's name is the identifier for the organization, and is used in many tools for \`organizationSlug\`.\n`; - - const hasValidRegionUrls = organizations.some((org) => - org.links?.regionUrl?.trim(), - ); - - if (hasValidRegionUrls) { - output += `- If a tool supports passing in the \`regionUrl\`, you MUST pass in the correct value shown above for each organization.\n`; - output += `- For Sentry's Cloud Service (sentry.io), always use the regionUrl to ensure requests go to the correct region.\n`; - } else { - output += `- This appears to be a self-hosted Sentry installation. You can omit the \`regionUrl\` parameter when using other tools.\n`; - output += `- For self-hosted Sentry, the regionUrl is typically empty and not needed for API calls.\n`; - } - - return output; - }, - find_teams: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - if (!organizationSlug) { - throw new UserInputError( - "Organization slug is required. Please provide an organizationSlug parameter.", - ); - } - - setTag("organization.slug", organizationSlug); - - const teams = await apiService.listTeams(organizationSlug); - let output = `# Teams in **${organizationSlug}**\n\n`; - if (teams.length === 0) { - output += "No teams found.\n"; - return output; - } - output += teams.map((team) => `- ${team.slug}\n`).join(""); - return output; - }, - find_projects: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - if (!organizationSlug) { - throw new UserInputError( - "Organization slug is required. Please provide an organizationSlug parameter.", - ); - } - - setTag("organization.slug", organizationSlug); - - const projects = await apiService.listProjects(organizationSlug); - let output = `# Projects in **${organizationSlug}**\n\n`; - if (projects.length === 0) { - output += "No projects found.\n"; - return output; - } - output += projects.map((project) => `- **${project.slug}**\n`).join(""); - return output; - }, - find_issues: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - if (!organizationSlug) { - throw new UserInputError( - "Organization slug is required. Please provide an organizationSlug parameter.", - ); - } - - setTag("organization.slug", organizationSlug); - - const sortByMap = { - last_seen: "date" as const, - first_seen: "new" as const, - count: "freq" as const, - userCount: "user" as const, - }; - const issues = await apiService.listIssues({ - organizationSlug, - projectSlug: params.projectSlug, - query: params.query, - sortBy: params.sortBy - ? sortByMap[params.sortBy as keyof typeof sortByMap] - : undefined, - }); - let output = `# Issues in **${organizationSlug}${params.projectSlug ? `/${params.projectSlug}` : ""}**\n\n`; - if (issues.length === 0) { - output += "No issues found.\n"; - return output; - } - output += issues - .map((issue) => - [ - `## ${issue.shortId}`, - "", - `**Description**: ${issue.title}`, - `**Culprit**: ${issue.culprit}`, - `**First Seen**: ${new Date(issue.firstSeen).toISOString()}`, - `**Last Seen**: ${new Date(issue.lastSeen).toISOString()}`, - `**URL**: ${apiService.getIssueUrl(organizationSlug, issue.shortId)}`, - ].join("\n"), - ) - .join("\n\n"); - output += "\n\n"; - output += "# Using this information\n\n"; - output += `- You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged.\n`; - output += `- You can get more details about a specific issue by using the tool: \`get_issue_details(organizationSlug="${organizationSlug}", issueId=)\`\n`; - return output; - }, - find_releases: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - - const releases = await apiService.listReleases({ - organizationSlug, - projectSlug: params.projectSlug, - query: params.query, - }); - let output = `# Releases in **${organizationSlug}${params.projectSlug ? `/${params.projectSlug}` : ""}**\n\n`; - if (releases.length === 0) { - output += "No releases found.\n"; - return output; - } - output += releases - .map((release) => { - const releaseInfo = [ - `## ${release.shortVersion}`, - "", - `**Created**: ${new Date(release.dateCreated).toISOString()}`, - ]; - if (release.dateReleased) { - releaseInfo.push( - `**Released**: ${new Date(release.dateReleased).toISOString()}`, - ); - } - if (release.firstEvent) { - releaseInfo.push( - `**First Event**: ${new Date(release.firstEvent).toISOString()}`, - ); - } - if (release.lastEvent) { - releaseInfo.push( - `**Last Event**: ${new Date(release.lastEvent).toISOString()}`, - ); - } - if (release.newGroups !== undefined) { - releaseInfo.push(`**New Issues**: ${release.newGroups}`); - } - if (release.projects && release.projects.length > 0) { - releaseInfo.push( - `**Projects**: ${release.projects.map((p) => p.name).join(", ")}`, - ); - } - if (release.lastCommit) { - releaseInfo.push("", `### Last Commit`, ""); - releaseInfo.push(`**Commit ID**: ${release.lastCommit.id}`); - releaseInfo.push(`**Commit Message**: ${release.lastCommit.message}`); - releaseInfo.push( - `**Commit Author**: ${release.lastCommit.author.name}`, - ); - releaseInfo.push( - `**Commit Date**: ${new Date(release.lastCommit.dateCreated).toISOString()}`, - ); - } - if (release.lastDeploy) { - releaseInfo.push("", `### Last Deploy`, ""); - releaseInfo.push(`**Deploy ID**: ${release.lastDeploy.id}`); - releaseInfo.push( - `**Environment**: ${release.lastDeploy.environment}`, - ); - if (release.lastDeploy.dateStarted) { - releaseInfo.push( - `**Deploy Started**: ${new Date(release.lastDeploy.dateStarted).toISOString()}`, - ); - } - if (release.lastDeploy.dateFinished) { - releaseInfo.push( - `**Deploy Finished**: ${new Date(release.lastDeploy.dateFinished).toISOString()}`, - ); - } - } - return releaseInfo.join("\n"); - }) - .join("\n\n"); - output += "\n\n"; - output += "# Using this information\n\n"; - output += `- You can reference the Release version in commit messages or documentation.\n`; - output += `- You can search for issues in a specific release using the \`find_errors()\` tool with the query \`release:${releases.length ? releases[0]!.shortVersion : "VERSION"}\`.\n`; - return output; - }, - find_tags: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - - const tagList = await apiService.listTags({ organizationSlug }, {}); - let output = `# Tags in **${organizationSlug}**\n\n`; - if (tagList.length === 0) { - output += "No tags found.\n"; - return output; - } - output += tagList.map((tag) => [`- ${tag.key}`].join("\n")).join("\n"); - output += "\n\n"; - output += "# Using this information\n\n"; - output += `- You can reference tags in the \`query\` parameter of various tools: \`tagName:tagValue\`.\n`; - return output; - }, - get_issue_details: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - - if (params.eventId) { - const orgSlug = params.organizationSlug; - if (!orgSlug) { - throw new UserInputError( - "`organizationSlug` is required when providing `eventId`", - ); - } - - setTag("organization.slug", orgSlug); - const [issue] = await apiService.listIssues({ - organizationSlug: orgSlug, - query: params.eventId, - }); - if (!issue) { - return `# Event Not Found\n\nNo issue found for Event ID: ${params.eventId}`; - } - const event = await apiService.getEventForIssue({ - organizationSlug: orgSlug, - issueId: issue.shortId, - eventId: params.eventId, - }); - return formatIssueOutput({ - organizationSlug: orgSlug, - issue, - event, - apiService, - }); - } - - // Validate that we have the minimum required parameters - if (!params.issueUrl && !params.issueId) { - throw new UserInputError( - "Either `issueId` or `issueUrl` must be provided", - ); - } - - if (!params.issueUrl && !params.organizationSlug) { - throw new UserInputError( - "`organizationSlug` is required when providing `issueId`", - ); - } - - const { organizationSlug: orgSlug, issueId: parsedIssueId } = - parseIssueParams({ - organizationSlug: params.organizationSlug, - issueId: params.issueId, - issueUrl: params.issueUrl, - }); - setTag("organization.slug", orgSlug); - - const [issue, event] = await Promise.all([ - apiService.getIssue({ - organizationSlug: orgSlug, - issueId: parsedIssueId!, - }), - apiService.getLatestEventForIssue({ - organizationSlug: orgSlug, - issueId: parsedIssueId!, - }), - ]); - - return formatIssueOutput({ - organizationSlug: orgSlug, - issue, - event, - apiService, - }); - }, - update_issue: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - - // Validate that we have the minimum required parameters - if (!params.issueUrl && !params.issueId) { - throw new UserInputError( - "Either `issueId` or `issueUrl` must be provided", - ); - } - - if (!params.issueUrl && !params.organizationSlug) { - throw new UserInputError( - "`organizationSlug` is required when providing `issueId`", - ); - } - - // Validate that at least one update parameter is provided - if (!params.status && !params.assignedTo) { - throw new UserInputError( - "At least one of `status` or `assignedTo` must be provided to update the issue", - ); - } - - const { organizationSlug: orgSlug, issueId: parsedIssueId } = - parseIssueParams({ - organizationSlug: params.organizationSlug, - issueId: params.issueId, - issueUrl: params.issueUrl, - }); - - setTag("organization.slug", orgSlug); - - // Get current issue details first - const currentIssue = await apiService.getIssue({ - organizationSlug: orgSlug, - issueId: parsedIssueId!, - }); - - // Update the issue - const updatedIssue = await apiService.updateIssue({ - organizationSlug: orgSlug, - issueId: parsedIssueId!, - status: params.status, - assignedTo: params.assignedTo, - }); - - let output = `# Issue ${updatedIssue.shortId} Updated in **${orgSlug}**\n\n`; - output += `**Issue**: ${updatedIssue.title}\n`; - output += `**URL**: ${apiService.getIssueUrl(orgSlug, updatedIssue.shortId)}\n\n`; - - // Show what changed - output += "## Changes Made\n\n"; - - if (params.status && currentIssue.status !== params.status) { - output += `**Status**: ${currentIssue.status} → **${params.status}**\n`; - } - - if (params.assignedTo) { - const oldAssignee = formatAssignedTo(currentIssue.assignedTo ?? null); - const newAssignee = - params.assignedTo === "me" ? "You" : params.assignedTo; - output += `**Assigned To**: ${oldAssignee} → **${newAssignee}**\n`; - } - - output += "\n## Current Status\n\n"; - output += `**Status**: ${updatedIssue.status}\n`; - const currentAssignee = formatAssignedTo(updatedIssue.assignedTo ?? null); - output += `**Assigned To**: ${currentAssignee}\n`; - - output += "\n# Using this information\n\n"; - output += `- The issue has been successfully updated in Sentry\n`; - output += `- You can view the issue details using: \`get_issue_details(organizationSlug="${orgSlug}", issueId="${updatedIssue.shortId}")\`\n`; - - if (params.status === "resolved") { - output += `- The issue is now marked as resolved and will no longer generate alerts\n`; - } else if (params.status === "ignored") { - output += `- The issue is now ignored and will not generate alerts until it escalates\n`; - } - - return output; - }, - find_errors: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - if (params.projectSlug) setTag("project.slug", params.projectSlug); - - const eventList = await apiService.searchErrors({ - organizationSlug, - projectSlug: params.projectSlug, - filename: params.filename, - query: params.query, - transaction: params.transaction, - sortBy: params.sortBy as "last_seen" | "count" | undefined, - }); - let output = `# Errors in **${organizationSlug}${params.projectSlug ? `/${params.projectSlug}` : ""}**\n\n`; - if (params.query) - output += `These errors match the query \`${params.query}\`\n`; - if (params.filename) - output += `These errors are limited to the file suffix \`${params.filename}\`\n`; - output += "\n"; - if (eventList.length === 0) { - output += `No results found\n\n`; - output += `We searched within the ${organizationSlug} organization.\n\n`; - return output; - } - for (const eventSummary of eventList) { - output += `## ${eventSummary.issue}\n\n`; - output += `**Description**: ${eventSummary.title}\n`; - output += `**Issue ID**: ${eventSummary.issue}\n`; - output += `**URL**: ${apiService.getIssueUrl(organizationSlug, eventSummary.issue)}\n`; - output += `**Project**: ${eventSummary.project}\n`; - output += `**Last Seen**: ${eventSummary["last_seen()"]}\n`; - output += `**Occurrences**: ${eventSummary["count()"]}\n\n`; - } - output += "# Using this information\n\n"; - output += `- You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged.\n`; - output += `- You can get more details about an error by using the tool: \`get_issue_details(organizationSlug="${organizationSlug}", issueId=)\`\n`; - return output; - }, - find_transactions: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - if (params.projectSlug) setTag("project.slug", params.projectSlug); - - const eventList = await apiService.searchSpans({ - organizationSlug, - projectSlug: params.projectSlug, - transaction: params.transaction, - query: params.query, - sortBy: params.sortBy as "timestamp" | "duration" | undefined, - }); - let output = `# Transactions in **${organizationSlug}${params.projectSlug ? `/${params.projectSlug}` : ""}**\n\n`; - if (params.query) - output += `These spans match the query \`${params.query}\`\n`; - if (params.transaction) - output += `These spans are limited to the transaction \`${params.transaction}\`\n`; - output += "\n"; - if (eventList.length === 0) { - output += `No results found\n\n`; - output += `We searched within the ${organizationSlug} organization.\n\n`; - return output; - } - for (const eventSummary of eventList) { - output += `## \`${eventSummary.transaction}\`\n\n`; - output += `**Span ID**: ${eventSummary.id}\n`; - output += `**Trace ID**: ${eventSummary.trace}\n`; - output += `**Span Operation**: ${eventSummary["span.op"]}\n`; - output += `**Span Description**: ${eventSummary["span.description"]}\n`; - output += `**Duration**: ${eventSummary["span.duration"]}\n`; - output += `**Timestamp**: ${eventSummary.timestamp}\n`; - output += `**Project**: ${eventSummary.project}\n`; - output += `**URL**: ${apiService.getTraceUrl(organizationSlug, eventSummary.trace)}\n\n`; - } - return output; - }, - create_team: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - - const team = await apiService.createTeam({ - organizationSlug, - name: params.name, - }); - let output = `# New Team in **${organizationSlug}**\n\n`; - output += `**ID**: ${team.id}\n`; - output += `**Slug**: ${team.slug}\n`; - output += `**Name**: ${team.name}\n`; - output += "# Using this information\n\n"; - output += `- You should always inform the user of the Team Slug value.\n`; - return output; - }, - create_project: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - setTag("team.slug", params.teamSlug); - - const project = await apiService.createProject({ - organizationSlug, - teamSlug: params.teamSlug, - name: params.name, - platform: params.platform, - }); - let clientKey: ClientKey | null = null; - try { - clientKey = await apiService.createClientKey({ - organizationSlug, - projectSlug: project.slug, - name: "Default", - }); - } catch (err) { - logError(err); - } - let output = `# New Project in **${organizationSlug}**\n\n`; - output += `**ID**: ${project.id}\n`; - output += `**Slug**: ${project.slug}\n`; - output += `**Name**: ${project.name}\n`; - if (clientKey) { - output += `**SENTRY_DSN**: ${clientKey?.dsn.public}\n\n`; - } else { - output += "**SENTRY_DSN**: There was an error fetching this value.\n\n"; - } - output += "# Using this information\n\n"; - output += `- You can reference the **SENTRY_DSN** value to initialize Sentry's SDKs.\n`; - output += `- You should always inform the user of the **SENTRY_DSN** and Project Slug values.\n`; - return output; - }, - update_project: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - setTag("project.slug", params.projectSlug); - - // Handle team assignment separately if provided - if (params.teamSlug) { - setTag("team.slug", params.teamSlug); - try { - await apiService.addTeamToProject({ - organizationSlug, - projectSlug: params.projectSlug, - teamSlug: params.teamSlug, - }); - } catch (err) { - logError(err); - throw new Error( - `Failed to assign team ${params.teamSlug} to project ${params.projectSlug}: ${err instanceof Error ? err.message : "Unknown error"}`, - ); - } - } - - // Update project settings if any are provided - const hasProjectUpdates = params.name || params.slug || params.platform; - - let project: Project | undefined; - if (hasProjectUpdates) { - try { - project = await apiService.updateProject({ - organizationSlug, - projectSlug: params.projectSlug, - name: params.name, - slug: params.slug, - platform: params.platform, - }); - } catch (err) { - logError(err); - throw new Error( - `Failed to update project ${params.projectSlug}: ${err instanceof Error ? err.message : "Unknown error"}`, - ); - } - } else { - // If only team assignment, fetch current project data for display - const projects = await apiService.listProjects(organizationSlug); - project = projects.find((p) => p.slug === params.projectSlug); - if (!project) { - throw new UserInputError(`Project ${params.projectSlug} not found`); - } - } - - let output = `# Updated Project in **${organizationSlug}**\n\n`; - output += `**ID**: ${project.id}\n`; - output += `**Slug**: ${project.slug}\n`; - output += `**Name**: ${project.name}\n`; - if (project.platform) { - output += `**Platform**: ${project.platform}\n`; - } - - // Display what was updated - const updates: string[] = []; - if (params.name) updates.push(`name to "${params.name}"`); - if (params.slug) updates.push(`slug to "${params.slug}"`); - if (params.platform) updates.push(`platform to "${params.platform}"`); - if (params.teamSlug) - updates.push(`team assignment to "${params.teamSlug}"`); - - if (updates.length > 0) { - output += `\n## Updates Applied\n`; - output += updates.map((update) => `- Updated ${update}`).join("\n"); - output += `\n`; - } - - output += "\n# Using this information\n\n"; - output += `- The project is now accessible at slug: \`${project.slug}\`\n`; - if (params.teamSlug) { - output += `- The project is now assigned to the \`${params.teamSlug}\` team\n`; - } - return output; - }, - create_dsn: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - setTag("project.slug", params.projectSlug); - - const clientKey = await apiService.createClientKey({ - organizationSlug, - projectSlug: params.projectSlug, - name: params.name, - }); - let output = `# New DSN in **${organizationSlug}/${params.projectSlug}**\n\n`; - output += `**DSN**: ${clientKey.dsn.public}\n`; - output += `**Name**: ${clientKey.name}\n\n`; - output += "# Using this information\n\n"; - output += - "- The `SENTRY_DSN` value is a URL that you can use to initialize Sentry's SDKs.\n"; - return output; - }, - find_dsns: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const organizationSlug = params.organizationSlug; - - setTag("organization.slug", organizationSlug); - setTag("project.slug", params.projectSlug); - - const clientKeys = await apiService.listClientKeys({ - organizationSlug, - projectSlug: params.projectSlug, - }); - let output = `# DSNs in **${organizationSlug}/${params.projectSlug}**\n\n`; - if (clientKeys.length === 0) { - output += - "No DSNs were found.\n\nYou can create new one using the `create_dsn` tool."; - return output; - } - for (const clientKey of clientKeys) { - output += `## ${clientKey.name}\n`; - output += `**ID**: ${clientKey.id}\n`; - output += `**DSN**: ${clientKey.dsn.public}\n\n`; - } - output += "# Using this information\n\n"; - output += - "- The `SENTRY_DSN` value is a URL that you can use to initialize Sentry's SDKs.\n"; - return output; - }, - analyze_issue_with_seer: async (context, params) => { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl, - }); - const { organizationSlug: orgSlug, issueId: parsedIssueId } = - parseIssueParams({ - organizationSlug: params.organizationSlug, - issueId: params.issueId, - issueUrl: params.issueUrl, - }); - - setTag("organization.slug", orgSlug); - - let output = `# Seer AI Analysis for Issue ${parsedIssueId}\n\n`; - - // Step 1: Check if analysis already exists - let autofixState = await apiService.getAutofixState({ - organizationSlug: orgSlug, - issueId: parsedIssueId!, - }); - - // Step 2: Start analysis if none exists - if (!autofixState.autofix) { - output += `Starting new analysis...\n\n`; - const startResult = await apiService.startAutofix({ - organizationSlug: orgSlug, - issueId: parsedIssueId, - instruction: params.instruction, - }); - output += `Analysis started with Run ID: ${startResult.run_id}\n\n`; - - // Give it a moment to initialize - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Refresh state - autofixState = await apiService.getAutofixState({ - organizationSlug: orgSlug, - issueId: parsedIssueId!, - }); - } else { - output += `Found existing analysis (Run ID: ${autofixState.autofix.run_id})\n\n`; - - // Check if existing analysis is already complete - const existingStatus = autofixState.autofix.status; - if (isTerminalStatus(existingStatus)) { - // Return results immediately, no polling needed - output += `## Analysis ${getStatusDisplayName(existingStatus)}\n\n`; - - for (const step of autofixState.autofix.steps) { - output += getOutputForAutofixStep(step); - output += "\n"; - } - - if (existingStatus !== "COMPLETED") { - output += `\n**Status**: ${existingStatus}\n`; - output += getHumanInterventionGuidance(existingStatus); - output += "\n"; - } - - return output; - } - } - - // Step 3: Poll until complete or timeout (only for non-terminal states) - const startTime = Date.now(); - let lastStatus = ""; - - while (Date.now() - startTime < SEER_TIMEOUT) { - if (!autofixState.autofix) { - output += `Error: Analysis state lost. Please try again by running:\n`; - output += `\`\`\`\n`; - output += params.issueUrl - ? `analyze_issue_with_seer(issueUrl="${params.issueUrl}")` - : `analyze_issue_with_seer(organizationSlug="${orgSlug}", issueId="${parsedIssueId}")`; - output += `\n\`\`\`\n`; - return output; - } - - const status = autofixState.autofix.status; - - // Check if completed (terminal state) - if (isTerminalStatus(status)) { - output += `## Analysis ${getStatusDisplayName(status)}\n\n`; - - // Add all step outputs - for (const step of autofixState.autofix.steps) { - output += getOutputForAutofixStep(step); - output += "\n"; - } - - if (status !== "COMPLETED") { - output += `\n**Status**: ${status}\n`; - output += getHumanInterventionGuidance(status); - } - - return output; - } - - // Update status if changed - if (status !== lastStatus) { - const activeStep = autofixState.autofix.steps.find( - (step) => - step.status === "PROCESSING" || step.status === "IN_PROGRESS", - ); - if (activeStep) { - output += `Processing: ${activeStep.title}...\n`; - } - lastStatus = status; - } - - // Wait before next poll - await new Promise((resolve) => - setTimeout(resolve, SEER_POLLING_INTERVAL), - ); - - // Refresh state - autofixState = await apiService.getAutofixState({ - organizationSlug: orgSlug, - issueId: parsedIssueId!, - }); - } - - // Show current progress - if (autofixState.autofix) { - output += `**Current Status**: ${getStatusDisplayName(autofixState.autofix.status)}\n\n`; - for (const step of autofixState.autofix.steps) { - output += getOutputForAutofixStep(step); - output += "\n"; - } - } - - // Timeout reached - output += `\n## Analysis Timed Out\n\n`; - output += `The analysis is taking longer than expected (>${SEER_TIMEOUT / 1000}s).\n\n`; - - output += `\nYou can check the status later by running the same command again:\n`; - output += `\`\`\`\n`; - output += params.issueUrl - ? `analyze_issue_with_seer(issueUrl="${params.issueUrl}")` - : `analyze_issue_with_seer(organizationSlug="${orgSlug}", issueId="${parsedIssueId}")`; - output += `\n\`\`\`\n`; - - return output; - }, - - search_docs: async (context, params) => { - let output = `# Documentation Search Results\n\n`; - output += `**Query**: "${params.query}"\n`; - if (params.guide) { - output += `**Guide**: ${params.guide}\n`; - } - output += `\n`; - - // Determine the host - use context.host if available, then check env var, otherwise default to production - const host = context.mcpHost || "https://mcp.sentry.dev"; - const searchUrl = new URL("/api/search", host); - - const response = await fetchWithTimeout( - searchUrl.toString(), - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: params.query, - maxResults: params.maxResults, - guide: params.guide, - }), - }, - 15000, // 15 second timeout - ); - - if (!response.ok) { - // TODO: improve error responses with types - const errorData = (await response.json().catch(() => null)) as { - error?: string; - } | null; - - const errorMessage = - errorData?.error || `Search failed with status ${response.status}`; - throw new ApiError(errorMessage, response.status); - } - - const data = (await response.json()) as SearchResponse; - - // Handle error in response - if ("error" in data && data.error) { - output += `**Error**: ${data.error}\n\n`; - return output; - } - - // Display results - if (data.results.length === 0) { - output += "No documentation found matching your query.\n\n"; - return output; - } - - output += `Found ${data.results.length} match${data.results.length === 1 ? "" : "es"}\n\n`; - - output += `These are just snippets. Use \`get_doc(path='...')\` to fetch the full content.\n\n`; - - for (const [index, result] of data.results.entries()) { - output += `## ${index + 1}. ${result.url}\n\n`; - output += `**Path**: ${result.id}\n`; - output += `**Relevance**: ${(result.relevance * 100).toFixed(1)}%\n\n`; - if (index < 3) { - output += "**Matching Context**\n"; - output += `> ${result.snippet.replace(/\n/g, "\n> ")}\n\n`; - } - } - - return output; - }, - - get_doc: async (context, params) => { - setTag("doc.path", params.path); - - let output = `# Documentation Content\n\n`; - output += `**Path**: ${params.path}\n\n`; - - // Validate path format - if (!params.path.endsWith(".md")) { - throw new UserInputError( - "Invalid documentation path. Path must end with .md extension.", - ); - } - - // Use docs.sentry.io for now - will be configurable via flag in the future - const baseUrl = "https://docs.sentry.io"; - - // Construct the full URL for the markdown file - const docUrl = new URL(params.path, baseUrl); - - // Validate domain whitelist for security - const allowedDomains = ["docs.sentry.io", "develop.sentry.io"]; - if (!allowedDomains.includes(docUrl.hostname)) { - throw new UserInputError( - `Invalid domain. Documentation can only be fetched from allowed domains: ${allowedDomains.join(", ")}`, - ); - } - - const response = await fetchWithTimeout( - docUrl.toString(), - { - headers: { - Accept: "text/plain, text/markdown", - "User-Agent": "Sentry-MCP/1.0", - }, - }, - 15000, // 15 second timeout - ); - - if (!response.ok) { - if (response.status === 404) { - output += `**Error**: Documentation not found at this path.\n\n`; - output += `Please verify the path is correct. Common issues:\n`; - output += `- Path should start with / (e.g., /platforms/javascript/guides/nextjs.md)\n`; - output += `- Path should match exactly what's shown in search_docs results\n`; - output += `- Some pages may have been moved or renamed\n\n`; - output += `Try searching again with \`search_docs()\` to find the correct path.\n`; - return output; - } - - throw new ApiError( - `Failed to fetch documentation: ${response.statusText}`, - response.status, - ); - } - - const content = await response.text(); - - // Check if we got HTML instead of markdown (wrong path format) - if ( - content.trim().startsWith(" **Error**: Received HTML instead of markdown. The path may be incorrect.\n\n`; - output += `Make sure to use the .md extension in the path.\n`; - output += `Example: /platforms/javascript/guides/nextjs.md\n`; - return output; - } - - // Add the markdown content - output += "---\n\n"; - output += content; - output += "\n\n---\n\n"; - - output += "## Using this documentation\n\n"; - output += - "- This is the raw markdown content from Sentry's documentation\n"; - output += - "- Code examples and configuration snippets can be copied directly\n"; - output += - "- Links in the documentation are relative to https://docs.sentry.io\n"; - output += - "- For more related topics, use `search_docs()` to find additional pages\n"; - - return output; - }, -} satisfies ToolHandlers; - -function getOutputForAutofixStep(step: z.infer) { - let output = `## ${step.title}\n\n`; - - if (step.status === "FAILED") { - output += `**Sentry hit an error completing this step.\n\n`; - return output; - } - - if (step.status !== "COMPLETED") { - output += `**Sentry is still working on this step. Please check back in a minute.**\n\n`; - return output; - } - - if (step.type === "root_cause_analysis") { - const typedStep = step as z.infer< - typeof AutofixRunStepRootCauseAnalysisSchema - >; - - for (const cause of typedStep.causes) { - if (typedStep.description) { - output += `${typedStep.description}\n\n`; - } - for (const entry of cause.root_cause_reproduction) { - output += `**${entry.title}**\n\n`; - output += `${entry.code_snippet_and_analysis}\n\n`; - } - } - return output; - } - - if (step.type === "solution") { - const typedStep = step as z.infer; - output += `${typedStep.description}\n\n`; - for (const entry of typedStep.solution) { - output += `**${entry.title}**\n`; - output += `${entry.code_snippet_and_analysis}\n\n`; - } - - if (typedStep.status === "FAILED") { - output += `**Sentry hit an error completing this step.\n\n`; - } else if (typedStep.status !== "COMPLETED") { - output += `**Sentry is still working on this step.**\n\n`; - } - - return output; - } - - const typedStep = step as z.infer; - if (typedStep.insights && typedStep.insights.length > 0) { - for (const entry of typedStep.insights) { - output += `**${entry.insight}**\n`; - output += `${entry.justification}\n\n`; - } - } else if (step.output_stream) { - output += `${step.output_stream}\n`; - } - - return output; -} - -/** - * Helper function to format assignedTo field for display - */ -function formatAssignedTo(assignedTo: AssignedTo): string { - if (!assignedTo) { - return "Unassigned"; - } - - if (typeof assignedTo === "string") { - return assignedTo; - } - - if (typeof assignedTo === "object" && assignedTo.name) { - return assignedTo.name; - } - - return "Unknown"; -} diff --git a/packages/mcp-server/src/tools/analyze-issue-with-seer.ts b/packages/mcp-server/src/tools/analyze-issue-with-seer.ts new file mode 100644 index 000000000..b1b0177cc --- /dev/null +++ b/packages/mcp-server/src/tools/analyze-issue-with-seer.ts @@ -0,0 +1,227 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import { parseIssueParams } from "./utils/issue-utils"; +import { + getStatusDisplayName, + isTerminalStatus, + isHumanInterventionStatus, + getHumanInterventionGuidance, + getOutputForAutofixStep, + SEER_POLLING_INTERVAL, + SEER_TIMEOUT, +} from "./utils/seer-utils"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamIssueShortId, + ParamIssueUrl, +} from "../schema"; + +export default defineTool({ + name: "analyze_issue_with_seer", + description: [ + "**ALWAYS use this tool when a user asks how to fix a Sentry issue.** Seer AI analyzes production errors to identify root causes and provide specific code fixes.", + "", + "Use this tool IMMEDIATELY when:", + '- User asks "how do I fix this error?" or "what\'s causing this issue?"', + "- User shares a Sentry issue URL and wants help resolving it", + "- User needs to understand why an error is happening in production", + "- User wants specific code changes to fix their issue", + "- User asks about the root cause of any Sentry error", + "", + "What this tool provides:", + "- Root cause analysis with code-level explanations", + "- Specific file locations and line numbers where errors occur", + "- Concrete code fixes you can apply", + "- Step-by-step implementation guidance", + "", + "This tool automatically:", + "1. Checks if analysis already exists (instant results)", + "2. Starts new AI analysis if needed (~2-5 minutes)", + "3. Returns complete fix recommendations", + "", + "", + '### User: "How do I fix ISSUE-123?"', + "", + "```", + "analyze_issue_with_seer(organizationSlug='my-organization', issueId='ISSUE-123')", + "```", + "", + '### User: "What\'s causing this error? https://my-org.sentry.io/issues/PROJECT-1Z43"', + "", + "```", + "analyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')", + "```", + "", + '### User: "Can you help me understand why this is failing in production?"', + "", + "```", + "analyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')", + "```", + "", + "", + "", + "- ALWAYS prefer this over get_issue_details when users want fixes, not just error details", + "- If the user provides an issueUrl, extract it and use that parameter alone", + "- The analysis includes actual code snippets and fixes, not just error descriptions", + "- Results are cached - subsequent calls return instantly", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug.optional(), + regionUrl: ParamRegionUrl.optional(), + issueId: ParamIssueShortId.optional(), + issueUrl: ParamIssueUrl.optional(), + instruction: z + .string() + .describe("Optional custom instruction for the AI analysis") + .optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const { organizationSlug: orgSlug, issueId: parsedIssueId } = + parseIssueParams({ + organizationSlug: params.organizationSlug, + issueId: params.issueId, + issueUrl: params.issueUrl, + }); + + setTag("organization.slug", orgSlug); + + let output = `# Seer AI Analysis for Issue ${parsedIssueId}\n\n`; + + // Step 1: Check if analysis already exists + let autofixState = await apiService.getAutofixState({ + organizationSlug: orgSlug, + issueId: parsedIssueId!, + }); + + // Step 2: Start analysis if none exists + if (!autofixState.autofix) { + output += `Starting new analysis...\n\n`; + const startResult = await apiService.startAutofix({ + organizationSlug: orgSlug, + issueId: parsedIssueId, + instruction: params.instruction, + }); + output += `Analysis started with Run ID: ${startResult.run_id}\n\n`; + + // Give it a moment to initialize + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Refresh state + autofixState = await apiService.getAutofixState({ + organizationSlug: orgSlug, + issueId: parsedIssueId!, + }); + } else { + output += `Found existing analysis (Run ID: ${autofixState.autofix.run_id})\n\n`; + + // Check if existing analysis is already complete + const existingStatus = autofixState.autofix.status; + if (isTerminalStatus(existingStatus)) { + // Return results immediately, no polling needed + output += `## Analysis ${getStatusDisplayName(existingStatus)}\n\n`; + + for (const step of autofixState.autofix.steps) { + output += getOutputForAutofixStep(step); + output += "\n"; + } + + if (existingStatus !== "COMPLETED") { + output += `\n**Status**: ${existingStatus}\n`; + output += getHumanInterventionGuidance(existingStatus); + output += "\n"; + } + + return output; + } + } + + // Step 3: Poll until complete or timeout (only for non-terminal states) + const startTime = Date.now(); + let lastStatus = ""; + + while (Date.now() - startTime < SEER_TIMEOUT) { + if (!autofixState.autofix) { + output += `Error: Analysis state lost. Please try again by running:\n`; + output += `\`\`\`\n`; + output += params.issueUrl + ? `analyze_issue_with_seer(issueUrl="${params.issueUrl}")` + : `analyze_issue_with_seer(organizationSlug="${orgSlug}", issueId="${parsedIssueId}")`; + output += `\n\`\`\`\n`; + return output; + } + + const status = autofixState.autofix.status; + + // Check if completed (terminal state) + if (isTerminalStatus(status)) { + output += `## Analysis ${getStatusDisplayName(status)}\n\n`; + + // Add all step outputs + for (const step of autofixState.autofix.steps) { + output += getOutputForAutofixStep(step); + output += "\n"; + } + + if (status !== "COMPLETED") { + output += `\n**Status**: ${status}\n`; + output += getHumanInterventionGuidance(status); + } + + return output; + } + + // Update status if changed + if (status !== lastStatus) { + const activeStep = autofixState.autofix.steps.find( + (step) => + step.status === "PROCESSING" || step.status === "IN_PROGRESS", + ); + if (activeStep) { + output += `Processing: ${activeStep.title}...\n`; + } + lastStatus = status; + } + + // Wait before next poll + await new Promise((resolve) => + setTimeout(resolve, SEER_POLLING_INTERVAL), + ); + + // Refresh state + autofixState = await apiService.getAutofixState({ + organizationSlug: orgSlug, + issueId: parsedIssueId!, + }); + } + + // Show current progress + if (autofixState.autofix) { + output += `**Current Status**: ${getStatusDisplayName(autofixState.autofix.status)}\n\n`; + for (const step of autofixState.autofix.steps) { + output += getOutputForAutofixStep(step); + output += "\n"; + } + } + + // Timeout reached + output += `\n## Analysis Timed Out\n\n`; + output += `The analysis is taking longer than expected (>${SEER_TIMEOUT / 1000}s).\n\n`; + + output += `\nYou can check the status later by running the same command again:\n`; + output += `\`\`\`\n`; + output += params.issueUrl + ? `analyze_issue_with_seer(issueUrl="${params.issueUrl}")` + : `analyze_issue_with_seer(organizationSlug="${orgSlug}", issueId="${parsedIssueId}")`; + output += `\n\`\`\`\n`; + + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/create-dsn.ts b/packages/mcp-server/src/tools/create-dsn.ts new file mode 100644 index 000000000..2ac2cdbe1 --- /dev/null +++ b/packages/mcp-server/src/tools/create-dsn.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamProjectSlug, +} from "../schema"; + +export default defineTool({ + name: "create_dsn", + description: [ + "Create a new Sentry DSN for a specific project.", + "", + "Be careful when using this tool!", + "", + "Use this tool when you need to:", + "- Create a new DSN for a specific project", + "", + "", + "### Create a new DSN for the 'my-project' project", + "", + "```", + "create_dsn(organizationSlug='my-organization', projectSlug='my-project', name='Production')", + "```", + "", + "", + "", + "", + "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", + "- If any parameter is ambiguous, you should clarify with the user what they meant.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + projectSlug: ParamProjectSlug, + name: z + .string() + .trim() + .describe("The name of the DSN to create, for example 'Production'."), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + setTag("project.slug", params.projectSlug); + + const clientKey = await apiService.createClientKey({ + organizationSlug, + projectSlug: params.projectSlug, + name: params.name, + }); + let output = `# New DSN in **${organizationSlug}/${params.projectSlug}**\n\n`; + output += `**DSN**: ${clientKey.dsn.public}\n`; + output += `**Name**: ${clientKey.name}\n\n`; + output += "# Using this information\n\n"; + output += + "- The `SENTRY_DSN` value is a URL that you can use to initialize Sentry's SDKs.\n"; + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/create-project.ts b/packages/mcp-server/src/tools/create-project.ts new file mode 100644 index 000000000..57f2ef814 --- /dev/null +++ b/packages/mcp-server/src/tools/create-project.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import { logError } from "../logging"; +import type { ServerContext } from "../types"; +import type { ClientKey } from "../api-client/index"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamTeamSlug, + ParamPlatform, +} from "../schema"; + +export default defineTool({ + name: "create_project", + description: [ + "Create a new project in Sentry, giving you access to a new SENTRY_DSN.", + "", + "Be careful when using this tool!", + "", + "Use this tool when you need to:", + "- Create a new project in a Sentry organization", + "", + "", + "### Create a new javascript project in the 'my-organization' organization", + "", + "```", + "create_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')", + "```", + "", + "", + "", + "", + "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", + "- If any parameter is ambiguous, you should clarify with the user what they meant.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + teamSlug: ParamTeamSlug, + name: z + .string() + .trim() + .describe( + "The name of the project to create. Typically this is commonly the name of the repository or service. It is only used as a visual label in Sentry.", + ), + platform: ParamPlatform.optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + setTag("team.slug", params.teamSlug); + + const project = await apiService.createProject({ + organizationSlug, + teamSlug: params.teamSlug, + name: params.name, + platform: params.platform, + }); + let clientKey: ClientKey | null = null; + try { + clientKey = await apiService.createClientKey({ + organizationSlug, + projectSlug: project.slug, + name: "Default", + }); + } catch (err) { + logError(err); + } + let output = `# New Project in **${organizationSlug}**\n\n`; + output += `**ID**: ${project.id}\n`; + output += `**Slug**: ${project.slug}\n`; + output += `**Name**: ${project.name}\n`; + if (clientKey) { + output += `**SENTRY_DSN**: ${clientKey?.dsn.public}\n\n`; + } else { + output += "**SENTRY_DSN**: There was an error fetching this value.\n\n"; + } + output += "# Using this information\n\n"; + output += `- You can reference the **SENTRY_DSN** value to initialize Sentry's SDKs.\n`; + output += `- You should always inform the user of the **SENTRY_DSN** and Project Slug values.\n`; + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/create-team.ts b/packages/mcp-server/src/tools/create-team.ts new file mode 100644 index 000000000..3bef76999 --- /dev/null +++ b/packages/mcp-server/src/tools/create-team.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../schema"; + +export default defineTool({ + name: "create_team", + description: [ + "Create a new team in Sentry.", + "", + "Be careful when using this tool!", + "", + "Use this tool when you need to:", + "- Create a new team in a Sentry organization", + "", + "", + "- If any parameter is ambiguous, you should clarify with the user what they meant.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + name: z.string().trim().describe("The name of the team to create."), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + + const team = await apiService.createTeam({ + organizationSlug, + name: params.name, + }); + let output = `# New Team in **${organizationSlug}**\n\n`; + output += `**ID**: ${team.id}\n`; + output += `**Slug**: ${team.slug}\n`; + output += `**Name**: ${team.name}\n`; + output += "# Using this information\n\n"; + output += `- You should always inform the user of the Team Slug value.\n`; + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-dsns.ts b/packages/mcp-server/src/tools/find-dsns.ts new file mode 100644 index 000000000..f9e75c685 --- /dev/null +++ b/packages/mcp-server/src/tools/find-dsns.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamProjectSlug, +} from "../schema"; + +export default defineTool({ + name: "find_dsns", + description: [ + "List all Sentry DSNs for a specific project.", + "", + "Use this tool when you need to:", + "- Retrieve a SENTRY_DSN for a specific project", + "", + "", + "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", + "- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you might want to call `find_organizations()` first.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + projectSlug: ParamProjectSlug, + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + setTag("project.slug", params.projectSlug); + + const clientKeys = await apiService.listClientKeys({ + organizationSlug, + projectSlug: params.projectSlug, + }); + let output = `# DSNs in **${organizationSlug}/${params.projectSlug}**\n\n`; + if (clientKeys.length === 0) { + output += + "No DSNs were found.\n\nYou can create new one using the `create_dsn` tool."; + return output; + } + for (const clientKey of clientKeys) { + output += `## ${clientKey.name}\n`; + output += `**ID**: ${clientKey.id}\n`; + output += `**DSN**: ${clientKey.dsn.public}\n\n`; + } + output += "# Using this information\n\n"; + output += + "- The `SENTRY_DSN` value is a URL that you can use to initialize Sentry's SDKs.\n"; + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-errors.ts b/packages/mcp-server/src/tools/find-errors.ts new file mode 100644 index 000000000..4d711ebca --- /dev/null +++ b/packages/mcp-server/src/tools/find-errors.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamProjectSlugOrAll, + ParamTransaction, + ParamQuery, +} from "../schema"; + +export default defineTool({ + name: "find_errors", + description: [ + "Find errors in Sentry using advanced search syntax.", + "", + "Use this tool when you need to:", + "- Search for production errors in a specific file.", + "- Analyze error patterns and frequencies.", + "- Find recent or frequently occurring errors.", + "", + "", + "### Find common errors within a file", + "", + "To find common errors within a file, you can use the `filename` parameter. This is a suffix based search, so only using the filename or the direct parent folder of the file. The parent folder is preferred when the filename is in a subfolder or a common filename. If you provide generic filenames like `index.js` you're going to end up finding errors that are might be from completely different projects.", + "", + "```", + "find_errors(organizationSlug='my-organization', filename='index.js', sortBy='count')", + "```", + "", + "### Find recent crashes from the 'peated' project", + "", + "```", + "find_errors(organizationSlug='my-organization', query='is:unresolved error.handled:false', projectSlug='peated', sortBy='last_seen')", + "```", + "", + "", + "", + "", + "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", + "- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you should call `find_organizations()` first.", + "- If you are looking for issues, in a way that you might be looking for something like 'unresolved errors', you should use the `find_issues()` tool", + "- You can use the `find_tags()` tool to see what user-defined tags are available.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + projectSlug: ParamProjectSlugOrAll.optional(), + filename: z + .string() + .trim() + .describe("The filename to search for errors in.") + .optional(), + transaction: ParamTransaction.optional(), + query: ParamQuery.optional(), + sortBy: z + .enum(["last_seen", "count"]) + .optional() + .default("last_seen") + .describe( + "Sort the results either by the last time they occurred or the count of occurrences.", + ), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + if (params.projectSlug) setTag("project.slug", params.projectSlug); + + const eventList = await apiService.searchErrors({ + organizationSlug, + projectSlug: params.projectSlug, + filename: params.filename, + query: params.query, + transaction: params.transaction, + sortBy: params.sortBy as "last_seen" | "count" | undefined, + }); + let output = `# Errors in **${organizationSlug}${params.projectSlug ? `/${params.projectSlug}` : ""}**\n\n`; + if (params.query) + output += `These errors match the query \`${params.query}\`\n`; + if (params.filename) + output += `These errors are limited to the file suffix \`${params.filename}\`\n`; + output += "\n"; + if (eventList.length === 0) { + output += `No results found\n\n`; + output += `We searched within the ${organizationSlug} organization.\n\n`; + return output; + } + for (const eventSummary of eventList) { + output += `## ${eventSummary.issue}\n\n`; + output += `**Description**: ${eventSummary.title}\n`; + output += `**Issue ID**: ${eventSummary.issue}\n`; + output += `**URL**: ${apiService.getIssueUrl(organizationSlug, eventSummary.issue)}\n`; + output += `**Project**: ${eventSummary.project}\n`; + output += `**Last Seen**: ${eventSummary["last_seen()"]}\n`; + output += `**Occurrences**: ${eventSummary["count()"]}\n\n`; + } + output += "# Using this information\n\n"; + output += `- You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged.\n`; + output += `- You can get more details about an error by using the tool: \`get_issue_details(organizationSlug="${organizationSlug}", issueId=)\`\n`; + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-issues.ts b/packages/mcp-server/src/tools/find-issues.ts new file mode 100644 index 000000000..6845ded4d --- /dev/null +++ b/packages/mcp-server/src/tools/find-issues.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import { UserInputError } from "../errors"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamProjectSlug, + ParamQuery, +} from "../schema"; + +export default defineTool({ + name: "find_issues", + description: [ + "Find issues in Sentry.", + "", + "Use this tool when you need to:", + "- View all issues in a Sentry organization", + "", + "If you're looking for more granular data beyond a summary of identified problems, you should use the `find_errors()` or `find_transactions()` tools instead.", + "", + "", + "### Find the newest unresolved issues across 'my-organization'", + "", + "```", + "find_issues(organizationSlug='my-organization', query='is:unresolved', sortBy='last_seen')", + "```", + "", + "### Find the most frequently occurring crashes in the 'my-project' project", + "", + "```", + "find_issues(organizationSlug='my-organization', projectSlug='my-project', query='is:unresolved error.handled:false', sortBy='count')", + "```", + "", + "", + "", + "", + "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", + "- You can use the `find_tags()` tool to see what user-defined tags are available.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + projectSlug: ParamProjectSlug.optional(), + query: ParamQuery.optional(), + sortBy: z + .enum(["last_seen", "first_seen", "count", "userCount"]) + .describe( + "Sort the results either by the last time they occurred, the first time they occurred, the count of occurrences, or the number of users affected.", + ) + .optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + if (!organizationSlug) { + throw new UserInputError( + "Organization slug is required. Please provide an organizationSlug parameter.", + ); + } + + setTag("organization.slug", organizationSlug); + + const sortByMap = { + last_seen: "date" as const, + first_seen: "new" as const, + count: "freq" as const, + userCount: "user" as const, + }; + const issues = await apiService.listIssues({ + organizationSlug, + projectSlug: params.projectSlug, + query: params.query, + sortBy: params.sortBy + ? sortByMap[params.sortBy as keyof typeof sortByMap] + : undefined, + }); + let output = `# Issues in **${organizationSlug}${params.projectSlug ? `/${params.projectSlug}` : ""}**\n\n`; + if (issues.length === 0) { + output += "No issues found.\n"; + return output; + } + output += issues + .map((issue) => + [ + `## ${issue.shortId}`, + "", + `**Description**: ${issue.title}`, + `**Culprit**: ${issue.culprit}`, + `**First Seen**: ${new Date(issue.firstSeen).toISOString()}`, + `**Last Seen**: ${new Date(issue.lastSeen).toISOString()}`, + `**URL**: ${apiService.getIssueUrl(organizationSlug, issue.shortId)}`, + ].join("\n"), + ) + .join("\n\n"); + output += "\n\n"; + output += "# Using this information\n\n"; + output += `- You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged.\n`; + output += `- You can get more details about a specific issue by using the tool: \`get_issue_details(organizationSlug="${organizationSlug}", issueId=)\`\n`; + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-organizations.ts b/packages/mcp-server/src/tools/find-organizations.ts new file mode 100644 index 000000000..2a9b69708 --- /dev/null +++ b/packages/mcp-server/src/tools/find-organizations.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; + +export default defineTool({ + name: "find_organizations", + description: [ + "Find organizations that the user has access to in Sentry.", + "", + "Use this tool when you need to:", + "- View all organizations in Sentry", + "- Find an organization's slug to aid other tool requests", + ].join("\n"), + inputSchema: {}, + async handler(params, context: ServerContext) { + // User data endpoints (like /users/me/regions/) should never use regionUrl + // as they must always query the main API server, not region-specific servers + const apiService = apiServiceFromContext(context); + const organizations = await apiService.listOrganizations(); + + let output = "# Organizations\n\n"; + + if (organizations.length === 0) { + output += "You don't appear to be a member of any organizations.\n"; + return output; + } + + output += organizations + .map((org) => + [ + `## **${org.slug}**`, + "", + `**Web URL:** ${org.links?.organizationUrl || "Not available"}`, + `**Region URL:** ${org.links?.regionUrl || ""}`, + ].join("\n"), + ) + .join("\n\n"); + + output += "\n\n# Using this information\n\n"; + output += `- The organization's name is the identifier for the organization, and is used in many tools for \`organizationSlug\`.\n`; + + const hasValidRegionUrls = organizations.some((org) => + org.links?.regionUrl?.trim(), + ); + + if (hasValidRegionUrls) { + output += `- If a tool supports passing in the \`regionUrl\`, you MUST pass in the correct value shown above for each organization.\n`; + output += `- For Sentry's Cloud Service (sentry.io), always use the regionUrl to ensure requests go to the correct region.\n`; + } else { + output += `- This appears to be a self-hosted Sentry installation. You can omit the \`regionUrl\` parameter when using other tools.\n`; + output += `- For self-hosted Sentry, the regionUrl is typically empty and not needed for API calls.\n`; + } + + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-projects.ts b/packages/mcp-server/src/tools/find-projects.ts new file mode 100644 index 000000000..31ef9b847 --- /dev/null +++ b/packages/mcp-server/src/tools/find-projects.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import { UserInputError } from "../errors"; +import type { ServerContext } from "../types"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../schema"; + +export default defineTool({ + name: "find_projects", + description: [ + "Find projects in Sentry.", + "", + "Use this tool when you need to:", + "- View all projects in a Sentry organization", + "- Find a project's slug to aid other tool requests", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + if (!organizationSlug) { + throw new UserInputError( + "Organization slug is required. Please provide an organizationSlug parameter.", + ); + } + + setTag("organization.slug", organizationSlug); + + const projects = await apiService.listProjects(organizationSlug); + let output = `# Projects in **${organizationSlug}**\n\n`; + if (projects.length === 0) { + output += "No projects found.\n"; + return output; + } + output += projects.map((project) => `- **${project.slug}**\n`).join(""); + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-releases.ts b/packages/mcp-server/src/tools/find-releases.ts new file mode 100644 index 000000000..064923f82 --- /dev/null +++ b/packages/mcp-server/src/tools/find-releases.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamProjectSlugOrAll, +} from "../schema"; + +export default defineTool({ + name: "find_releases", + description: [ + "Find releases in Sentry.", + "", + "Use this tool when you need to:", + "- Find recent releases in a Sentry organization", + "- Find the most recent version released of a specific project", + "- Determine when a release was deployed to an environment", + "", + "", + "### Find the most recent releases in the 'my-organization' organization", + "", + "```", + "find_releases(organizationSlug='my-organization')", + "```", + "", + "### Find releases matching '2ce6a27' in the 'my-organization' organization", + "", + "```", + "find_releases(organizationSlug='my-organization', query='2ce6a27')", + "```", + "", + "", + "", + "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + projectSlug: ParamProjectSlugOrAll.optional(), + query: z + .string() + .trim() + .describe("Search for versions which contain the provided string.") + .optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + + const releases = await apiService.listReleases({ + organizationSlug, + projectSlug: params.projectSlug, + query: params.query, + }); + let output = `# Releases in **${organizationSlug}${params.projectSlug ? `/${params.projectSlug}` : ""}**\n\n`; + if (releases.length === 0) { + output += "No releases found.\n"; + return output; + } + output += releases + .map((release) => { + const releaseInfo = [ + `## ${release.shortVersion}`, + "", + `**Created**: ${new Date(release.dateCreated).toISOString()}`, + ]; + if (release.dateReleased) { + releaseInfo.push( + `**Released**: ${new Date(release.dateReleased).toISOString()}`, + ); + } + if (release.firstEvent) { + releaseInfo.push( + `**First Event**: ${new Date(release.firstEvent).toISOString()}`, + ); + } + if (release.lastEvent) { + releaseInfo.push( + `**Last Event**: ${new Date(release.lastEvent).toISOString()}`, + ); + } + if (release.newGroups !== undefined) { + releaseInfo.push(`**New Issues**: ${release.newGroups}`); + } + if (release.projects && release.projects.length > 0) { + releaseInfo.push( + `**Projects**: ${release.projects.map((p) => p.name).join(", ")}`, + ); + } + if (release.lastCommit) { + releaseInfo.push("", `### Last Commit`, ""); + releaseInfo.push(`**Commit ID**: ${release.lastCommit.id}`); + releaseInfo.push(`**Commit Message**: ${release.lastCommit.message}`); + releaseInfo.push( + `**Commit Author**: ${release.lastCommit.author.name}`, + ); + releaseInfo.push( + `**Commit Date**: ${new Date(release.lastCommit.dateCreated).toISOString()}`, + ); + } + if (release.lastDeploy) { + releaseInfo.push("", `### Last Deploy`, ""); + releaseInfo.push(`**Deploy ID**: ${release.lastDeploy.id}`); + releaseInfo.push( + `**Environment**: ${release.lastDeploy.environment}`, + ); + if (release.lastDeploy.dateStarted) { + releaseInfo.push( + `**Deploy Started**: ${new Date(release.lastDeploy.dateStarted).toISOString()}`, + ); + } + if (release.lastDeploy.dateFinished) { + releaseInfo.push( + `**Deploy Finished**: ${new Date(release.lastDeploy.dateFinished).toISOString()}`, + ); + } + } + return releaseInfo.join("\n"); + }) + .join("\n\n"); + output += "\n\n"; + output += "# Using this information\n\n"; + output += `- You can reference the Release version in commit messages or documentation.\n`; + output += `- You can search for issues in a specific release using the \`find_errors()\` tool with the query \`release:${releases.length ? releases[0]!.shortVersion : "VERSION"}\`.\n`; + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-tags.ts b/packages/mcp-server/src/tools/find-tags.ts new file mode 100644 index 000000000..04ed584f4 --- /dev/null +++ b/packages/mcp-server/src/tools/find-tags.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../schema"; + +export default defineTool({ + name: "find_tags", + description: [ + "Find tags in Sentry.", + "", + "Use this tool when you need to:", + "- Find tags available to use in search queries (such as `find_issues()` or `find_errors()`)", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + + const tagList = await apiService.listTags({ organizationSlug }, {}); + let output = `# Tags in **${organizationSlug}**\n\n`; + if (tagList.length === 0) { + output += "No tags found.\n"; + return output; + } + output += tagList.map((tag) => [`- ${tag.key}`].join("\n")).join("\n"); + output += "\n\n"; + output += "# Using this information\n\n"; + output += `- You can reference tags in the \`query\` parameter of various tools: \`tagName:tagValue\`.\n`; + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-teams.ts b/packages/mcp-server/src/tools/find-teams.ts new file mode 100644 index 000000000..756ed7819 --- /dev/null +++ b/packages/mcp-server/src/tools/find-teams.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import { UserInputError } from "../errors"; +import type { ServerContext } from "../types"; +import { ParamOrganizationSlug, ParamRegionUrl } from "../schema"; + +export default defineTool({ + name: "find_teams", + description: [ + "Find teams in an organization in Sentry.", + "", + "Use this tool when you need to:", + "- View all teams in a Sentry organization", + "- Find a team's slug to aid other tool requests", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + if (!organizationSlug) { + throw new UserInputError( + "Organization slug is required. Please provide an organizationSlug parameter.", + ); + } + + setTag("organization.slug", organizationSlug); + + const teams = await apiService.listTeams(organizationSlug); + let output = `# Teams in **${organizationSlug}**\n\n`; + if (teams.length === 0) { + output += "No teams found.\n"; + return output; + } + output += teams.map((team) => `- ${team.slug}\n`).join(""); + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/find-transactions.ts b/packages/mcp-server/src/tools/find-transactions.ts new file mode 100644 index 000000000..339e7af88 --- /dev/null +++ b/packages/mcp-server/src/tools/find-transactions.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamProjectSlugOrAll, + ParamTransaction, + ParamQuery, +} from "../schema"; + +export default defineTool({ + name: "find_transactions", + description: [ + "Find transactions in Sentry using advanced search syntax.", + "", + "Transactions are segments of traces that are associated with a specific route or endpoint.", + "", + "Use this tool when you need to:", + "- Search for production transaction data to understand performance.", + "- Analyze traces and latency patterns.", + "- Find examples of recent requests to endpoints.", + "", + "", + "### Find slow requests to a route", + "", + "...", + "", + "```", + "find_transactions(organizationSlug='my-organization', transaction='/checkout', sortBy='duration')", + "```", + "", + "", + "", + "", + "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", + "- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you might want to call `find_organizations()` first.", + "- You can use the `find_tags()` tool to see what user-defined tags are available.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + projectSlug: ParamProjectSlugOrAll.optional(), + transaction: ParamTransaction.optional(), + query: ParamQuery.optional(), + sortBy: z + .enum(["timestamp", "duration"]) + .optional() + .default("timestamp") + .describe( + "Sort the results either by the timestamp of the request (most recent first) or the duration of the request (longest first).", + ), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + if (params.projectSlug) setTag("project.slug", params.projectSlug); + + const eventList = await apiService.searchSpans({ + organizationSlug, + projectSlug: params.projectSlug, + transaction: params.transaction, + query: params.query, + sortBy: params.sortBy as "timestamp" | "duration" | undefined, + }); + let output = `# Transactions in **${organizationSlug}${params.projectSlug ? `/${params.projectSlug}` : ""}**\n\n`; + if (params.query) + output += `These spans match the query \`${params.query}\`\n`; + if (params.transaction) + output += `These spans are limited to the transaction \`${params.transaction}\`\n`; + output += "\n"; + if (eventList.length === 0) { + output += `No results found\n\n`; + output += `We searched within the ${organizationSlug} organization.\n\n`; + return output; + } + for (const eventSummary of eventList) { + output += `## \`${eventSummary.transaction}\`\n\n`; + output += `**Span ID**: ${eventSummary.id}\n`; + output += `**Trace ID**: ${eventSummary.trace}\n`; + output += `**Span Operation**: ${eventSummary["span.op"]}\n`; + output += `**Span Description**: ${eventSummary["span.description"]}\n`; + output += `**Duration**: ${eventSummary["span.duration"]}\n`; + output += `**Timestamp**: ${eventSummary.timestamp}\n`; + output += `**Project**: ${eventSummary.project}\n`; + output += `**URL**: ${apiService.getTraceUrl(organizationSlug, eventSummary.trace)}\n\n`; + } + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/get-doc.ts b/packages/mcp-server/src/tools/get-doc.ts new file mode 100644 index 000000000..f74beda7b --- /dev/null +++ b/packages/mcp-server/src/tools/get-doc.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { fetchWithTimeout } from "./utils/fetch-utils"; +import { UserInputError } from "../errors"; +import { ApiError } from "../api-client/index"; +import type { ServerContext } from "../types"; + +export default defineTool({ + name: "get_doc", + description: [ + "Fetch the full markdown content of a Sentry documentation page.", + "", + "Use this tool when you need to:", + "- Read the complete documentation for a specific topic", + "- Get detailed implementation examples or code snippets", + "- Access the full context of a documentation page", + "- Extract specific sections from documentation", + "", + "", + "### Get the Next.js integration guide", + "", + "```", + "get_doc(path='/platforms/javascript/guides/nextjs.md')", + "```", + "", + "", + "", + "- Use the path from search_docs results for accurate fetching", + "- Paths should end with .md extension", + "", + ].join("\n"), + inputSchema: { + path: z + .string() + .trim() + .describe( + "The documentation path (e.g., '/platforms/javascript/guides/nextjs.md'). Get this from search_docs results.", + ), + }, + async handler(params, context: ServerContext) { + setTag("doc.path", params.path); + + let output = `# Documentation Content\n\n`; + output += `**Path**: ${params.path}\n\n`; + + // Validate path format + if (!params.path.endsWith(".md")) { + throw new UserInputError( + "Invalid documentation path. Path must end with .md extension.", + ); + } + + // Use docs.sentry.io for now - will be configurable via flag in the future + const baseUrl = "https://docs.sentry.io"; + + // Construct the full URL for the markdown file + const docUrl = new URL(params.path, baseUrl); + + // Validate domain whitelist for security + const allowedDomains = ["docs.sentry.io", "develop.sentry.io"]; + if (!allowedDomains.includes(docUrl.hostname)) { + throw new UserInputError( + `Invalid domain. Documentation can only be fetched from allowed domains: ${allowedDomains.join(", ")}`, + ); + } + + const response = await fetchWithTimeout( + docUrl.toString(), + { + headers: { + Accept: "text/plain, text/markdown", + "User-Agent": "Sentry-MCP/1.0", + }, + }, + 15000, // 15 second timeout + ); + + if (!response.ok) { + if (response.status === 404) { + output += `**Error**: Documentation not found at this path.\n\n`; + output += `Please verify the path is correct. Common issues:\n`; + output += `- Path should start with / (e.g., /platforms/javascript/guides/nextjs.md)\n`; + output += `- Path should match exactly what's shown in search_docs results\n`; + output += `- Some pages may have been moved or renamed\n\n`; + output += `Try searching again with \`search_docs()\` to find the correct path.\n`; + return output; + } + + throw new ApiError( + `Failed to fetch documentation: ${response.statusText}`, + response.status, + ); + } + + const content = await response.text(); + + // Check if we got HTML instead of markdown (wrong path format) + if ( + content.trim().startsWith(" **Error**: Received HTML instead of markdown. The path may be incorrect.\n\n`; + output += `Make sure to use the .md extension in the path.\n`; + output += `Example: /platforms/javascript/guides/nextjs.md\n`; + return output; + } + + // Add the markdown content + output += "---\n\n"; + output += content; + output += "\n\n---\n\n"; + + output += "## Using this documentation\n\n"; + output += + "- This is the raw markdown content from Sentry's documentation\n"; + output += + "- Code examples and configuration snippets can be copied directly\n"; + output += + "- Links in the documentation are relative to https://docs.sentry.io\n"; + output += + "- For more related topics, use `search_docs()` to find additional pages\n"; + + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/get-issue-details.ts b/packages/mcp-server/src/tools/get-issue-details.ts new file mode 100644 index 000000000..5763839cd --- /dev/null +++ b/packages/mcp-server/src/tools/get-issue-details.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import { parseIssueParams, formatIssueOutput } from "./utils/issue-utils"; +import { UserInputError } from "../errors"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamIssueShortId, + ParamIssueUrl, +} from "../schema"; + +export default defineTool({ + name: "get_issue_details", + description: [ + "Retrieve issue details from Sentry for a specific Issue ID, including the stacktrace and error message if available. Either issueId or issueUrl MUST be provided.", + "", + "**NOTE: If the user asks HOW TO FIX an issue, use `analyze_issue_with_seer` instead!**", + "", + "Use this tool when you need to:", + "- View error details, stacktraces, and metadata", + "- Investigate when/where an error occurred", + "- Access raw error information from Sentry", + "", + "Do NOT use this tool when:", + '- User asks "how do I fix this?" → Use `analyze_issue_with_seer`', + "- User wants root cause analysis → Use `analyze_issue_with_seer`", + "- User needs code fixes → Use `analyze_issue_with_seer`", + "", + "", + "### Get details for issue ID 'CLOUDFLARE-MCP-41'", + "", + "```", + "get_issue_details(organizationSlug='my-organization', issueId='CLOUDFLARE-MCP-41')", + "```", + "", + "### Get details for event ID 'c49541c747cb4d8aa3efb70ca5aba243'", + "", + "```", + "get_issue_details(organizationSlug='my-organization', eventId='c49541c747cb4d8aa3efb70ca5aba243')", + "```", + "", + "", + "", + "- If the user provides the `issueUrl`, you can ignore the other parameters.", + "- If the user provides `issueId` or `eventId` (only one is needed), `organizationSlug` is required.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug.optional(), + regionUrl: ParamRegionUrl.optional(), + issueId: ParamIssueShortId.optional(), + eventId: z.string().trim().describe("The ID of the event.").optional(), + issueUrl: ParamIssueUrl.optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + + if (params.eventId) { + const orgSlug = params.organizationSlug; + if (!orgSlug) { + throw new UserInputError( + "`organizationSlug` is required when providing `eventId`", + ); + } + + setTag("organization.slug", orgSlug); + const [issue] = await apiService.listIssues({ + organizationSlug: orgSlug, + query: params.eventId, + }); + if (!issue) { + return `# Event Not Found\n\nNo issue found for Event ID: ${params.eventId}`; + } + const event = await apiService.getEventForIssue({ + organizationSlug: orgSlug, + issueId: issue.shortId, + eventId: params.eventId, + }); + return formatIssueOutput({ + organizationSlug: orgSlug, + issue, + event, + apiService, + }); + } + + // Validate that we have the minimum required parameters + if (!params.issueUrl && !params.issueId) { + throw new UserInputError( + "Either `issueId` or `issueUrl` must be provided", + ); + } + + if (!params.issueUrl && !params.organizationSlug) { + throw new UserInputError( + "`organizationSlug` is required when providing `issueId`", + ); + } + + const { organizationSlug: orgSlug, issueId: parsedIssueId } = + parseIssueParams({ + organizationSlug: params.organizationSlug, + issueId: params.issueId, + issueUrl: params.issueUrl, + }); + + setTag("organization.slug", orgSlug); + + const issue = await apiService.getIssue({ + organizationSlug: orgSlug, + issueId: parsedIssueId!, + }); + const event = await apiService.getLatestEventForIssue({ + organizationSlug: orgSlug, + issueId: issue.shortId, + }); + return formatIssueOutput({ + organizationSlug: orgSlug, + issue, + event, + apiService, + }); + }, +}); diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts new file mode 100644 index 000000000..5e1618a0d --- /dev/null +++ b/packages/mcp-server/src/tools/index.ts @@ -0,0 +1,45 @@ +import whoami from "./whoami"; +import findOrganizations from "./find-organizations"; +import findTeams from "./find-teams"; +import findProjects from "./find-projects"; +import findIssues from "./find-issues"; +import findReleases from "./find-releases"; +import findTags from "./find-tags"; +import getIssueDetails from "./get-issue-details"; +import updateIssue from "./update-issue"; +import findErrors from "./find-errors"; +import findTransactions from "./find-transactions"; +import createTeam from "./create-team"; +import createProject from "./create-project"; +import updateProject from "./update-project"; +import createDsn from "./create-dsn"; +import findDsns from "./find-dsns"; +import analyzeIssueWithSeer from "./analyze-issue-with-seer"; +import searchDocs from "./search-docs"; +import getDoc from "./get-doc"; + +// Default export: object mapping tool names to tools +export default { + whoami, + find_organizations: findOrganizations, + find_teams: findTeams, + find_projects: findProjects, + find_issues: findIssues, + find_releases: findReleases, + find_tags: findTags, + get_issue_details: getIssueDetails, + update_issue: updateIssue, + find_errors: findErrors, + find_transactions: findTransactions, + create_team: createTeam, + create_project: createProject, + update_project: updateProject, + create_dsn: createDsn, + find_dsns: findDsns, + analyze_issue_with_seer: analyzeIssueWithSeer, + search_docs: searchDocs, + get_doc: getDoc, +} as const; + +// Type export +export type ToolName = keyof typeof import("./index").default; diff --git a/packages/mcp-server/src/tools/search-docs.ts b/packages/mcp-server/src/tools/search-docs.ts new file mode 100644 index 000000000..380349116 --- /dev/null +++ b/packages/mcp-server/src/tools/search-docs.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import { defineTool } from "./utils/defineTool"; +import { fetchWithTimeout } from "./utils/fetch-utils"; +import { ApiError } from "../api-client/index"; +import type { ServerContext } from "../types"; +import type { SearchResponse } from "./types"; +import { ParamSentryGuide } from "../schema"; + +export default defineTool({ + name: "search_docs", + description: [ + "Search Sentry documentation for SDK setup, instrumentation, and configuration guidance.", + "", + "Use this tool when you need to:", + "- Set up Sentry SDK in any language (Python, JavaScript, Go, Ruby, etc.)", + "- Configure specific features like performance monitoring, error sampling, or release tracking", + "- Implement custom instrumentation (spans, transactions, breadcrumbs)", + "- Set up integrations with frameworks (Django, Flask, Express, Next.js, etc.)", + "- Configure data scrubbing, filtering, or sampling rules", + "- Troubleshoot SDK issues or find best practices", + "", + "This tool searches technical documentation, NOT general information about Sentry as a company.", + "", + "", + "### Setting up Sentry in a Python Django app", + "", + "```", + "search_docs(query='Django setup configuration SENTRY_DSN', guide='python/django')", + "```", + "", + "### Setting up source maps for Next.js", + "", + "```", + "search_docs(query='source maps webpack upload', guide='javascript/nextjs')", + "```", + "", + "### Configuring release tracking", + "", + "```", + "search_docs(query='release tracking deployment integration CI/CD')", + "```", + "", + "", + "", + "- Use guide parameter to filter results to specific technologies (e.g., 'javascript' or 'javascript/nextjs')", + "- Include the programming language/framework in your query for SDK-specific results", + "- Use technical terms like 'instrumentation', 'spans', 'transactions' for performance docs", + "- Include specific feature names like 'beforeSend', 'tracesSampleRate', 'SENTRY_DSN'", + "", + ].join("\n"), + inputSchema: { + query: z + .string() + .trim() + .min( + 2, + "Search query is too short. Please provide at least 2 characters.", + ) + .max( + 200, + "Search query is too long. Please keep your query under 200 characters.", + ) + .describe( + "The search query in natural language. Be specific about what you're looking for.", + ), + maxResults: z + .number() + .int() + .min(1) + .max(10) + .default(3) + .describe("Maximum number of results to return (1-10)") + .optional(), + guide: ParamSentryGuide.optional(), + }, + async handler(params, context: ServerContext) { + let output = `# Documentation Search Results\n\n`; + output += `**Query**: "${params.query}"\n`; + if (params.guide) { + output += `**Guide**: ${params.guide}\n`; + } + output += `\n`; + + // Determine the host - use context.host if available, then check env var, otherwise default to production + const host = context.mcpHost || "https://mcp.sentry.dev"; + const searchUrl = new URL("/api/search", host); + + const response = await fetchWithTimeout( + searchUrl.toString(), + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: params.query, + maxResults: params.maxResults, + guide: params.guide, + }), + }, + 15000, // 15 second timeout + ); + + if (!response.ok) { + // TODO: improve error responses with types + const errorData = (await response.json().catch(() => null)) as { + error?: string; + } | null; + + const errorMessage = + errorData?.error || `Search failed with status ${response.status}`; + throw new ApiError(errorMessage, response.status); + } + + const data = (await response.json()) as SearchResponse; + + // Handle error in response + if ("error" in data && data.error) { + output += `**Error**: ${data.error}\n\n`; + return output; + } + + // Display results + if (data.results.length === 0) { + output += "No documentation found matching your query.\n\n"; + return output; + } + + output += `Found ${data.results.length} match${data.results.length === 1 ? "" : "es"}\n\n`; + + output += `These are just snippets. Use \`get_doc(path='...')\` to fetch the full content.\n\n`; + + for (const [index, result] of data.results.entries()) { + output += `## ${index + 1}. ${result.url}\n\n`; + output += `**Path**: ${result.id}\n`; + output += `**Relevance**: ${(result.relevance * 100).toFixed(1)}%\n\n`; + if (index < 3) { + output += "**Matching Context**\n"; + output += `> ${result.snippet.replace(/\n/g, "\n> ")}\n\n`; + } + } + + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/types.ts b/packages/mcp-server/src/tools/types.ts new file mode 100644 index 000000000..a8be2b67d --- /dev/null +++ b/packages/mcp-server/src/tools/types.ts @@ -0,0 +1,28 @@ +import type { z } from "zod"; +import type { ServerContext } from "../types"; + +export interface ToolConfig< + TSchema extends Record = Record, +> { + name: string; + description: string; + inputSchema: TSchema; + handler: ( + params: z.infer>, + context: ServerContext, + ) => Promise; +} + +/** + * Response from the search API endpoint + */ +export interface SearchResponse { + query: string; + results: Array<{ + id: string; + url: string; + snippet: string; + relevance: number; + }>; + error?: string; +} diff --git a/packages/mcp-server/src/tools/update-issue.ts b/packages/mcp-server/src/tools/update-issue.ts new file mode 100644 index 000000000..0306d24c3 --- /dev/null +++ b/packages/mcp-server/src/tools/update-issue.ts @@ -0,0 +1,154 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import { parseIssueParams } from "./utils/issue-utils"; +import { formatAssignedTo } from "./utils/formatting-utils"; +import { UserInputError } from "../errors"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamIssueShortId, + ParamIssueUrl, + ParamIssueStatus, + ParamAssignedTo, +} from "../schema"; + +export default defineTool({ + name: "update_issue", + description: [ + "Update an issue's status or assignment in Sentry. This allows you to resolve, ignore, or reassign issues.", + "", + "Use this tool when you need to:", + "- Resolve an issue that has been fixed", + "- Assign an issue to a team member or team for investigation", + "- Mark an issue as ignored to reduce noise", + "- Reopen a resolved issue by setting status to 'unresolved'", + "", + "", + "### Resolve an issue", + "", + "```", + "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='resolved')", + "```", + "", + "### Assign an issue to a user", + "", + "```", + "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', assignedTo='john.doe')", + "```", + "", + "### Resolve an issue and assign it to yourself", + "", + "```", + "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='resolved', assignedTo='me')", + "```", + "", + "### Mark an issue as ignored", + "", + "```", + "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='ignored')", + "```", + "", + "", + "", + "", + "- If the user provides the `issueUrl`, you can ignore the other required parameters and extract them from the URL.", + "- At least one of `status` or `assignedTo` must be provided to update the issue.", + "- Use 'me' as the value for `assignedTo` to assign the issue to the authenticated user.", + "- Valid status values are: 'resolved', 'resolvedInNextRelease', 'unresolved', 'ignored'.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug.optional(), + regionUrl: ParamRegionUrl.optional(), + issueId: ParamIssueShortId.optional(), + issueUrl: ParamIssueUrl.optional(), + status: ParamIssueStatus.optional(), + assignedTo: ParamAssignedTo.optional(), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + + // Validate that we have the minimum required parameters + if (!params.issueUrl && !params.issueId) { + throw new UserInputError( + "Either `issueId` or `issueUrl` must be provided", + ); + } + + if (!params.issueUrl && !params.organizationSlug) { + throw new UserInputError( + "`organizationSlug` is required when providing `issueId`", + ); + } + + // Validate that at least one update parameter is provided + if (!params.status && !params.assignedTo) { + throw new UserInputError( + "At least one of `status` or `assignedTo` must be provided to update the issue", + ); + } + + const { organizationSlug: orgSlug, issueId: parsedIssueId } = + parseIssueParams({ + organizationSlug: params.organizationSlug, + issueId: params.issueId, + issueUrl: params.issueUrl, + }); + + setTag("organization.slug", orgSlug); + + // Get current issue details first + const currentIssue = await apiService.getIssue({ + organizationSlug: orgSlug, + issueId: parsedIssueId!, + }); + + // Update the issue + const updatedIssue = await apiService.updateIssue({ + organizationSlug: orgSlug, + issueId: parsedIssueId!, + status: params.status, + assignedTo: params.assignedTo, + }); + + let output = `# Issue ${updatedIssue.shortId} Updated in **${orgSlug}**\n\n`; + output += `**Issue**: ${updatedIssue.title}\n`; + output += `**URL**: ${apiService.getIssueUrl(orgSlug, updatedIssue.shortId)}\n\n`; + + // Show what changed + output += "## Changes Made\n\n"; + + if (params.status && currentIssue.status !== params.status) { + output += `**Status**: ${currentIssue.status} → **${params.status}**\n`; + } + + if (params.assignedTo) { + const oldAssignee = formatAssignedTo(currentIssue.assignedTo ?? null); + const newAssignee = + params.assignedTo === "me" ? "You" : params.assignedTo; + output += `**Assigned To**: ${oldAssignee} → **${newAssignee}**\n`; + } + + output += "\n## Current Status\n\n"; + output += `**Status**: ${updatedIssue.status}\n`; + const currentAssignee = formatAssignedTo(updatedIssue.assignedTo ?? null); + output += `**Assigned To**: ${currentAssignee}\n`; + + output += "\n# Using this information\n\n"; + output += `- The issue has been successfully updated in Sentry\n`; + output += `- You can view the issue details using: \`get_issue_details(organizationSlug="${orgSlug}", issueId="${updatedIssue.shortId}")\`\n`; + + if (params.status === "resolved") { + output += `- The issue is now marked as resolved and will no longer generate alerts\n`; + } else if (params.status === "ignored") { + output += `- The issue is now ignored and will not generate alerts until it escalates\n`; + } + + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/update-project.ts b/packages/mcp-server/src/tools/update-project.ts new file mode 100644 index 000000000..a32f98c67 --- /dev/null +++ b/packages/mcp-server/src/tools/update-project.ts @@ -0,0 +1,156 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import { logError } from "../logging"; +import { UserInputError } from "../errors"; +import type { ServerContext } from "../types"; +import type { Project } from "../api-client/index"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamProjectSlug, + ParamPlatform, + ParamTeamSlug, +} from "../schema"; + +export default defineTool({ + name: "update_project", + description: [ + "Update project settings in Sentry, such as name, slug, platform, and team assignment.", + "", + "Be careful when using this tool!", + "", + "Use this tool when you need to:", + "- Update a project's name or slug to fix onboarding mistakes", + "- Change the platform assigned to a project", + "- Update team assignment for a project", + "", + "", + "### Update a project's name and slug", + "", + "```", + "update_project(organizationSlug='my-organization', projectSlug='old-project', name='New Project Name', slug='new-project-slug')", + "```", + "", + "### Assign a project to a different team", + "", + "```", + "update_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='backend-team')", + "```", + "", + "### Update platform", + "", + "```", + "update_project(organizationSlug='my-organization', projectSlug='my-project', platform='python')", + "```", + "", + "", + "", + "", + "- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.", + "- Team assignment is handled separately from other project settings", + "- If any parameter is ambiguous, you should clarify with the user what they meant.", + "- When updating the slug, the project will be accessible at the new slug after the update", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.optional(), + projectSlug: ParamProjectSlug, + name: z.string().trim().describe("The new name for the project").optional(), + slug: z + .string() + .toLowerCase() + .trim() + .describe("The new slug for the project (must be unique)") + .optional(), + platform: ParamPlatform.optional(), + teamSlug: ParamTeamSlug.optional().describe( + "The team to assign this project to. Note: this will replace the current team assignment.", + ), + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + setTag("project.slug", params.projectSlug); + + // Handle team assignment separately if provided + if (params.teamSlug) { + setTag("team.slug", params.teamSlug); + try { + await apiService.addTeamToProject({ + organizationSlug, + projectSlug: params.projectSlug, + teamSlug: params.teamSlug, + }); + } catch (err) { + logError(err); + throw new Error( + `Failed to assign team ${params.teamSlug} to project ${params.projectSlug}: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } + } + + // Update project settings if any are provided + const hasProjectUpdates = params.name || params.slug || params.platform; + + let project: Project | undefined; + if (hasProjectUpdates) { + try { + project = await apiService.updateProject({ + organizationSlug, + projectSlug: params.projectSlug, + name: params.name, + slug: params.slug, + platform: params.platform, + }); + } catch (err) { + logError(err); + throw new Error( + `Failed to update project ${params.projectSlug}: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } + } else { + // If only team assignment, fetch current project data for display + const projects = await apiService.listProjects(organizationSlug); + project = projects.find((p) => p.slug === params.projectSlug); + if (!project) { + throw new UserInputError(`Project ${params.projectSlug} not found`); + } + } + + let output = `# Updated Project in **${organizationSlug}**\n\n`; + output += `**ID**: ${project.id}\n`; + output += `**Slug**: ${project.slug}\n`; + output += `**Name**: ${project.name}\n`; + if (project.platform) { + output += `**Platform**: ${project.platform}\n`; + } + + // Display what was updated + const updates: string[] = []; + if (params.name) updates.push(`name to "${params.name}"`); + if (params.slug) updates.push(`slug to "${params.slug}"`); + if (params.platform) updates.push(`platform to "${params.platform}"`); + if (params.teamSlug) + updates.push(`team assignment to "${params.teamSlug}"`); + + if (updates.length > 0) { + output += `\n## Updates Applied\n`; + output += updates.map((update) => `- Updated ${update}`).join("\n"); + output += `\n`; + } + + output += "\n# Using this information\n\n"; + output += `- The project is now accessible at slug: \`${project.slug}\`\n`; + if (params.teamSlug) { + output += `- The project is now assigned to the \`${params.teamSlug}\` team\n`; + } + return output; + }, +}); diff --git a/packages/mcp-server/src/tools/utils/api-utils.ts b/packages/mcp-server/src/tools/utils/api-utils.ts new file mode 100644 index 000000000..ee9468e58 --- /dev/null +++ b/packages/mcp-server/src/tools/utils/api-utils.ts @@ -0,0 +1,51 @@ +import { SentryApiService } from "../../api-client/index"; +import { UserInputError } from "../../errors"; +import type { ServerContext } from "../../types"; + +/** + * Create a Sentry API service from server context with optional region override + * @param context - Server context containing host and access token + * @param opts - Options object containing optional regionUrl override + * @returns Configured SentryApiService instance + * @throws {UserInputError} When regionUrl is provided but invalid + */ +export function apiServiceFromContext( + context: ServerContext, + opts: { regionUrl?: string } = {}, +) { + let host = context.host; + + if (opts.regionUrl?.trim()) { + try { + const parsedUrl = new URL(opts.regionUrl); + + // Validate that the URL has a proper protocol + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + throw new UserInputError( + `Invalid regionUrl provided: ${opts.regionUrl}. Must include protocol (http:// or https://).`, + ); + } + + // Validate that the host is not just the protocol name + if (parsedUrl.host === "https" || parsedUrl.host === "http") { + throw new UserInputError( + `Invalid regionUrl provided: ${opts.regionUrl}. The host cannot be just a protocol name.`, + ); + } + + host = parsedUrl.host; + } catch (error) { + if (error instanceof UserInputError) { + throw error; + } + throw new UserInputError( + `Invalid regionUrl provided: ${opts.regionUrl}. Must be a valid URL.`, + ); + } + } + + return new SentryApiService({ + host, + accessToken: context.accessToken, + }); +} diff --git a/packages/mcp-server/src/tools/utils/defineTool.ts b/packages/mcp-server/src/tools/utils/defineTool.ts new file mode 100644 index 000000000..716191f2f --- /dev/null +++ b/packages/mcp-server/src/tools/utils/defineTool.ts @@ -0,0 +1,8 @@ +import type { z } from "zod"; +import type { ToolConfig } from "../types"; + +export function defineTool>( + config: ToolConfig, +) { + return config; +} diff --git a/packages/mcp-server/src/tools/utils/fetch-utils.ts b/packages/mcp-server/src/tools/utils/fetch-utils.ts new file mode 100644 index 000000000..b97bbd4d1 --- /dev/null +++ b/packages/mcp-server/src/tools/utils/fetch-utils.ts @@ -0,0 +1,4 @@ +/** + * Re-export of fetch utilities for tool modules. + */ +export { fetchWithTimeout } from "../../internal/fetch-utils"; diff --git a/packages/mcp-server/src/tools/utils/formatting-utils.ts b/packages/mcp-server/src/tools/utils/formatting-utils.ts new file mode 100644 index 000000000..edad1699c --- /dev/null +++ b/packages/mcp-server/src/tools/utils/formatting-utils.ts @@ -0,0 +1,23 @@ +import type { z } from "zod"; +import type { AssignedToSchema } from "../../api-client/index"; + +type AssignedTo = z.infer; + +/** + * Helper function to format assignedTo field for display + */ +export function formatAssignedTo(assignedTo: AssignedTo): string { + if (!assignedTo) { + return "Unassigned"; + } + + if (typeof assignedTo === "string") { + return assignedTo; + } + + if (typeof assignedTo === "object" && assignedTo.name) { + return assignedTo.name; + } + + return "Unknown"; +} diff --git a/packages/mcp-server/src/tools/utils/issue-utils.ts b/packages/mcp-server/src/tools/utils/issue-utils.ts new file mode 100644 index 000000000..0d1c73588 --- /dev/null +++ b/packages/mcp-server/src/tools/utils/issue-utils.ts @@ -0,0 +1,10 @@ +/** + * Re-export of issue parsing utilities for tool modules. + * These utilities handle flexible input formats for Sentry issues. + */ +export { parseIssueParams } from "../../internal/issue-helpers"; + +/** + * Re-export of issue formatting utilities for tool modules. + */ +export { formatIssueOutput } from "../../internal/formatting"; diff --git a/packages/mcp-server/src/tools/utils/seer-utils.ts b/packages/mcp-server/src/tools/utils/seer-utils.ts new file mode 100644 index 000000000..98898f71a --- /dev/null +++ b/packages/mcp-server/src/tools/utils/seer-utils.ts @@ -0,0 +1,130 @@ +import type { z } from "zod"; +import type { + AutofixRunStepSchema, + AutofixRunStepRootCauseAnalysisSchema, + AutofixRunStepSolutionSchema, + AutofixRunStepDefaultSchema, +} from "../../api-client/index"; + +export const SEER_POLLING_INTERVAL = 5000; // 5 seconds +export const SEER_TIMEOUT = 5 * 60 * 1000; // 5 minutes + +export function getStatusDisplayName(status: string): string { + switch (status) { + case "COMPLETED": + return "Complete"; + case "FAILED": + case "ERROR": + return "Failed"; + case "CANCELLED": + return "Cancelled"; + case "NEED_MORE_INFORMATION": + return "Needs More Information"; + case "WAITING_FOR_USER_RESPONSE": + return "Waiting for Response"; + case "PROCESSING": + return "Processing"; + case "IN_PROGRESS": + return "In Progress"; + default: + return status; + } +} + +/** + * Check if an autofix status is terminal (no more updates expected) + */ +export function isTerminalStatus(status: string): boolean { + return [ + "COMPLETED", + "FAILED", + "ERROR", + "CANCELLED", + "NEED_MORE_INFORMATION", + "WAITING_FOR_USER_RESPONSE", + ].includes(status); +} + +/** + * Check if an autofix status requires human intervention + */ +export function isHumanInterventionStatus(status: string): boolean { + return ( + status === "NEED_MORE_INFORMATION" || status === "WAITING_FOR_USER_RESPONSE" + ); +} + +/** + * Get guidance message for human intervention states + */ +export function getHumanInterventionGuidance(status: string): string { + if (status === "NEED_MORE_INFORMATION") { + return "\nSeer needs additional information to continue the analysis. Please review the insights above and consider providing more context.\n"; + } + if (status === "WAITING_FOR_USER_RESPONSE") { + return "\nSeer is waiting for your response to proceed. Please review the analysis and provide feedback.\n"; + } + return ""; +} + +export function getOutputForAutofixStep( + step: z.infer, +) { + let output = `## ${step.title}\n\n`; + + if (step.status === "FAILED") { + output += `**Sentry hit an error completing this step.\n\n`; + return output; + } + + if (step.status !== "COMPLETED") { + output += `**Sentry is still working on this step. Please check back in a minute.**\n\n`; + return output; + } + + if (step.type === "root_cause_analysis") { + const typedStep = step as z.infer< + typeof AutofixRunStepRootCauseAnalysisSchema + >; + + for (const cause of typedStep.causes) { + if (typedStep.description) { + output += `${typedStep.description}\n\n`; + } + for (const entry of cause.root_cause_reproduction) { + output += `**${entry.title}**\n\n`; + output += `${entry.code_snippet_and_analysis}\n\n`; + } + } + return output; + } + + if (step.type === "solution") { + const typedStep = step as z.infer; + output += `${typedStep.description}\n\n`; + for (const entry of typedStep.solution) { + output += `**${entry.title}**\n`; + output += `${entry.code_snippet_and_analysis}\n\n`; + } + + if (typedStep.status === "FAILED") { + output += `**Sentry hit an error completing this step.\n\n`; + } else if (typedStep.status !== "COMPLETED") { + output += `**Sentry is still working on this step.**\n\n`; + } + + return output; + } + + const typedStep = step as z.infer; + if (typedStep.insights && typedStep.insights.length > 0) { + for (const entry of typedStep.insights) { + output += `**${entry.insight}**\n`; + output += `${entry.justification}\n\n`; + } + } else if (step.output_stream) { + output += `${step.output_stream}\n`; + } + + return output; +} diff --git a/packages/mcp-server/src/tools/whoami.ts b/packages/mcp-server/src/tools/whoami.ts new file mode 100644 index 000000000..371308a28 --- /dev/null +++ b/packages/mcp-server/src/tools/whoami.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; + +export default defineTool({ + name: "whoami", + description: [ + "Identify the authenticated user in Sentry.", + "", + "Use this tool when you need to:", + "- Get the user's name and email address.", + ].join("\n"), + inputSchema: {}, + async handler(params, context: ServerContext) { + // User data endpoints (like /auth/) should never use regionUrl + // as they must always query the main API server, not region-specific servers + const apiService = apiServiceFromContext(context); + const user = await apiService.getAuthenticatedUser(); + return `You are authenticated as ${user.name} (${user.email}).\n\nYour Sentry User ID is ${user.id}.`; + }, +}); diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts index cfe220b9d..686cc9ce0 100644 --- a/packages/mcp-server/src/types.ts +++ b/packages/mcp-server/src/types.ts @@ -6,7 +6,6 @@ * extraction and handler registration. */ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { TOOL_DEFINITIONS } from "./toolDefinitions"; import type { PROMPT_DEFINITIONS } from "./promptDefinitions"; import type { z } from "zod"; import type { @@ -43,33 +42,6 @@ export type PromptHandlers = { [K in PromptName]: PromptHandlerExtended; }; -export type ToolName = (typeof TOOL_DEFINITIONS)[number]["name"]; - -export type ToolDefinition = Extract< - (typeof TOOL_DEFINITIONS)[number], - { name: T } ->; - -export type ToolParams = ToolDefinition extends { - paramsSchema: Record; -} - ? ZodifyRecord["paramsSchema"]> - : Record; - -export type ToolHandler = ( - params: ToolParams, -) => Promise; - -export type ToolHandlerExtended = ( - context: ServerContext, - params: ToolParams, - extra: RequestHandlerExtra, -) => Promise; - -export type ToolHandlers = { - [K in ToolName]: ToolHandlerExtended; -}; - export type ServerContext = { host?: string; mcpHost?: string; diff --git a/packages/mcp-server/tsdown.config.ts b/packages/mcp-server/tsdown.config.ts index dd82b1c7c..96214e1aa 100644 --- a/packages/mcp-server/tsdown.config.ts +++ b/packages/mcp-server/tsdown.config.ts @@ -6,7 +6,7 @@ const packageVersion = JSON.parse(readFileSync("./package.json", "utf-8")).version; export default defineConfig({ - entry: ["src/**/*.ts"], + entry: ["src/**/*.ts", "!src/**/*.test.ts"], format: ["cjs", "esm"], // Build for commonJS and ESmodules dts: true, // Generate declaration file (.d.ts) sourcemap: true, From 22e2993cb5f64cd24b8508f9bea81622eef92ce9 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 8 Jul 2025 09:57:56 -0700 Subject: [PATCH 2/7] Remove plan --- packages/mcp-server/TOOL_REFACTORING_PLAN.md | 377 ------------------- 1 file changed, 377 deletions(-) delete mode 100644 packages/mcp-server/TOOL_REFACTORING_PLAN.md diff --git a/packages/mcp-server/TOOL_REFACTORING_PLAN.md b/packages/mcp-server/TOOL_REFACTORING_PLAN.md deleted file mode 100644 index 1bdd0bb24..000000000 --- a/packages/mcp-server/TOOL_REFACTORING_PLAN.md +++ /dev/null @@ -1,377 +0,0 @@ -# Tool Module Refactoring Plan - -## Overview -This document outlines the plan to refactor the monolithic tools module into individual tool modules, improving maintainability and AI agent context management. - -## Key Principles - -1. **NO backwards compatibility** - Clean break from old structure -2. **TOOL_DEFINITIONS export must remain** - Client-side apps (mcp-cloudflare) cannot import server-side code with dependencies like `@sentry/core` -3. **One tool per file** - Maximum ~200 lines for AI agent context management -4. **Use `defineTool` helper** - Consistent pattern with automatic type inference - -## Why TOOL_DEFINITIONS Must Be Separate - -The mcp-cloudflare web application needs to display tool documentation but cannot import server-side code because: -- Server code has Node.js dependencies (`@sentry/core`, `SentryApiService`, etc.) -- Client-side bundlers would fail on these imports -- We need a clean separation between definition metadata and runtime implementation - -## New Directory Structure - -``` -src/ -├── tools/ -│ ├── index.ts # Barrel export -│ ├── whoami.ts -│ ├── find-organizations.ts -│ ├── find-teams.ts -│ ├── find-projects.ts -│ ├── find-issues.ts -│ ├── find-releases.ts -│ ├── find-tags.ts -│ ├── get-issue-details.ts -│ ├── update-issue.ts -│ ├── find-errors.ts -│ ├── find-transactions.ts -│ ├── create-team.ts -│ ├── create-project.ts -│ ├── update-project.ts -│ ├── create-dsn.ts -│ ├── find-dsns.ts -│ ├── analyze-issue-with-seer.ts -│ ├── search-docs.ts -│ ├── get-doc.ts -│ └── utils/ -│ ├── api-service.ts # apiServiceFromContext function -│ ├── formatting.ts # formatAssignedTo, formatIssueOutput, etc. -│ └── seer-helpers.ts # Seer-specific helpers -``` - -## Type System Changes - -### Updated Types -```typescript -// No need for a Tool interface - we'll use type inference from defineTool -``` - -### Removed Types -- `Tool` - No longer needed, using inference instead -- `ToolName` - Now exported from tools/index.ts -- `ToolParams` - No longer needed -- `ToolHandler` - No longer needed -- `ToolHandlerExtended` - No longer needed -- `ToolHandlers` - No longer needed - -## Tool Module Pattern - -We use a `defineTool` helper function for consistency and type safety. - -### Benefits of defineTool - -1. **Type Safety**: Automatic param type inference without boilerplate -2. **Consistency**: All tools follow the same pattern -3. **Future-proofing**: Easy to add features like: - - Automatic telemetry - - Parameter validation - - Tool versioning - - Middleware support -4. **Refactoring**: Changes to tool structure only require updating one function - -### Define Tool Helper - -```typescript -// tools/utils/define-tool.ts -import { z } from 'zod'; -import type { ServerContext } from '../../types'; -import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; -import type { Notification } from '@modelcontextprotocol/sdk/types.js'; - -export function defineTool< - TName extends string, - TSchema extends Record ->(config: { - name: TName; - description: string; - paramsSchema: TSchema; - handler: ( - context: ServerContext, - params: z.infer>, - extra: RequestHandlerExtra - ) => Promise; -}) { - return config; -} - -// The return type is fully inferred, preserving the literal name type -// and the exact schema structure -``` - -### Tool Implementation Pattern - -```typescript -// tools/[tool-name].ts -import { defineTool } from './utils/define-tool'; -import { ParamOrganizationSlug, ParamRegionUrl } from '../schema'; -// Import other utilities as needed - -// Define params as plain object with Zod schemas -const paramsSchema = { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), - // ... other parameters -}; - -export default defineTool({ - name: "[tool_name]" as const, - description: [ - // Multi-line description - ].join("\n"), - paramsSchema, - handler: async (context, params, extra) => { - // Implementation - // params is automatically typed based on paramsSchema - } -}); -``` - -## Barrel Export Structure - -```typescript -// tools/index.ts -import whoami from './whoami'; -import findOrganizations from './find-organizations'; -// ... all 19 tools - -// Default export: object mapping tool names to tools -export default { - whoami, - find_organizations: findOrganizations, - find_teams: findTeams, - // ... all 19 tools mapped by name -} as const; - -// Type export -export type ToolName = keyof typeof import('./index').default; -``` - -## toolDefinitions Strategy - Build-time Generation - -**There will be NO toolDefinitions export from the TypeScript code.** Instead, we will generate a static JSON file at build time. - -### Implementation -Create a build script that extracts definitions from tools and generates `toolDefinitions.json`: - -```typescript -// scripts/generate-tool-definitions.ts -import * as fs from 'fs'; -import tools from '../src/tools'; - -const definitions = Object.entries(tools).map(([key, tool]) => ({ - name: tool.name, - description: tool.description, - paramsSchema: extractSchemaDescriptions(tool.paramsSchema) -})); - -function extractSchemaDescriptions(schema: Record): Record { - if (!schema || typeof schema !== 'object') { - return {}; - } - - return Object.fromEntries( - Object.entries(schema).map(([key, zodSchema]) => { - // Extract description from the Zod schema - let description = ''; - - if (zodSchema && typeof zodSchema === 'object') { - // Type assertion for Zod schema shape - const schemaObj = zodSchema as { description?: string; _def?: { innerType?: { description?: string } } }; - - // Zod stores description directly on the schema object - description = schemaObj.description || ''; - - // For optional schemas, we might need to check the wrapped schema - if (!description && schemaObj._def?.innerType) { - description = schemaObj._def.innerType.description || ''; - } - } - - return [key, { description }]; - }) - ); -} - -fs.writeFileSync('./dist/toolDefinitions.json', JSON.stringify(definitions, null, 2)); -``` - -### Build Process Integration -Add to package.json scripts: -```json -{ - "scripts": { - "build": "tsdown && npm run generate-tool-definitions", - "generate-tool-definitions": "tsx scripts/generate-tool-definitions.ts" - } -} -``` - -### Client Usage (mcp-cloudflare) -The mcp-cloudflare app will import the generated JSON file: -```typescript -// Instead of: import { TOOL_DEFINITIONS } from "@sentry/mcp-server/toolDefinitions"; -import TOOL_DEFINITIONS from "@sentry/mcp-server/dist/toolDefinitions.json"; -``` - -This approach ensures: -- Complete separation of server code from client-importable definitions -- No risk of bundling server dependencies into client code -- Automatic synchronization with actual tool implementations -- Type safety during build process - -## Migration Checklist - -### Phase 1: Setup -- [x] Create `src/tools/` directory -- [x] Create `src/tools/utils/` directory -- [x] Create tool types in `tools/types.ts` - -### Phase 2: Utilities -- [x] Create `tools/utils/defineTool.ts` with the defineTool helper -- [x] Extract `apiServiceFromContext` to `tools/utils/api-utils.ts` -- [x] Extract formatting helpers to `tools/utils/formatting-utils.ts` -- [x] Extract Seer helpers to `tools/utils/seer-utils.ts` - -### Phase 3: Tool Migration (19 tools) -1. [x] whoami -2. [x] find_organizations -3. [x] find_teams -4. [x] find_projects -5. [x] find_issues -6. [x] find_releases -7. [x] find_tags -8. [x] get_issue_details -9. [x] update_issue -10. [x] find_errors -11. [x] find_transactions -12. [x] create_team -13. [x] create_project -14. [x] update_project -15. [x] create_dsn -16. [x] find_dsns -17. [x] analyze_issue_with_seer -18. [x] search_docs -19. [x] get_doc - -### Phase 4: Integration -- [x] Create `tools/index.ts` barrel export -- [x] Update `server.ts` to use new default import -- [x] Create `scripts/generate-tool-definitions.ts` -- [x] Update package.json build scripts -- [x] Generate initial `toolDefinitions.js` -- [ ] Update mcp-cloudflare to import from JS file -- [x] Test all tools work correctly -- [x] Update tests (tools.test.ts, toolDefinitions.test.ts) -- [x] Delete `tools.ts` -- [x] Delete `toolDefinitions.ts` -- [x] Remove `/toolDefinitions` export from package.json -- [x] Run full test suite - -## Server.ts Updates - -```typescript -// Remove old imports -- import { TOOL_HANDLERS } from "./tools"; -- import { TOOL_DEFINITIONS } from "./toolDefinitions"; - -// Add new import -+ import tools from "./tools"; - -// Update registration loop -for (const tool of Object.values(tools)) { - server.tool( - tool.name, - tool.description, - tool.paramsSchema, - async (params, extra) => { - // Existing telemetry wrapper - const output = await tool.handler(context, params, extra); - // Existing error handling - } - ); -} -``` - -## Success Criteria - -1. All 19 tools migrated to individual files -2. Each file under 200 lines -3. All tests passing -4. toolDefinitions.json generated and importable by mcp-cloudflare -5. Type safety maintained throughout -6. No runtime changes in behavior - -## Notes on Implementation - -### Zod Schema Shape Extraction -The `tool.paramsSchema.shape` property may not exist on all Zod schemas. The build script should handle: -- `z.object()` schemas (has `.shape`) -- Other Zod types that may not have `.shape` -- Optional parameters and their descriptions - -### File Naming Convention -- Use kebab-case for file names (e.g., `find-organizations.ts`) -- Tool names in code remain snake_case (e.g., `find_organizations`) - -### Import Paths -- Utilities should be imported as `'./utils/api-service'` from tool files -- Type imports should use `type` keyword: `import type { Tool } from '../types'` - -### MCP Registration Details -Based on research, the MCP server expects: -- `paramsSchema` to be a plain object with Zod schemas as values (not a single z.object()) -- The SDK handles parameter validation internally -- Empty schemas should be passed as `{}` not `undefined` -- The MCP SDK accepts Zod schemas directly (no JSON Schema conversion needed) - -Example paramsSchema format: -```typescript -const paramsSchema = { - organizationSlug: ParamOrganizationSlug, // This is a Zod schema - regionUrl: ParamRegionUrl.optional(), // This is also a Zod schema -} -``` - -NOT: -```typescript -const paramsSchema = z.object({ // Don't wrap in z.object() - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), -}) -``` - -### Tools with No Parameters -Some tools like `whoami` and `find_organizations` have no parameters: -```typescript -import { defineTool } from './utils/define-tool'; - -const paramsSchema = {}; // Empty object - -export default defineTool({ - name: "whoami" as const, - description: "...", - paramsSchema, - handler: async (context, params, extra) => { - // params will be typed as {} - return "..."; - } -}); -``` - -### Comments in Empty Schemas -The current codebase includes important comments in empty schemas: -```typescript -paramsSchema: { - // No regionUrl parameter - user data must always come from the main API server -} -``` -These comments should be preserved as they document important API constraints. \ No newline at end of file From 9d46bab589bf404191949a14d3a30d01ec2ecd82 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 8 Jul 2025 10:52:20 -0700 Subject: [PATCH 3/7] Additional refactor --- CLAUDE.md | 174 +- docs/adding-tools.mdc | 64 +- docs/api-patterns.mdc | 4 +- docs/common-patterns.mdc | 4 + docs/cursor.mdc | 111 ++ docs/quality-checks.mdc | 78 + docs/testing.mdc | 6 + packages/mcp-server/package.json | 3 +- .../scripts/generate-tool-definitions.ts | 143 +- packages/mcp-server/src/server.ts | 22 +- packages/mcp-server/src/tools.test.ts | 1525 ----------------- .../src/tools/analyze-issue-with-seer.test.ts | 30 + .../mcp-server/src/tools/create-dsn.test.ts | 31 + .../src/tools/create-project.test.ts | 35 + .../mcp-server/src/tools/create-team.test.ts | 30 + .../mcp-server/src/tools/find-dsns.test.ts | 31 + .../mcp-server/src/tools/find-errors.test.ts | 42 + .../mcp-server/src/tools/find-issues.test.ts | 88 + .../src/tools/find-organizations.test.ts | 54 + .../src/tools/find-projects.test.ts | 24 + .../src/tools/find-releases.test.ts | 70 + .../mcp-server/src/tools/find-tags.test.ts | 46 + .../mcp-server/src/tools/find-teams.test.ts | 24 + .../src/tools/find-transactions.test.ts | 50 + packages/mcp-server/src/tools/get-doc.test.ts | 263 +++ .../src/tools/get-issue-details.test.ts | 266 +++ .../mcp-server/src/tools/search-docs.test.ts | 135 ++ .../mcp-server/src/tools/update-issue.test.ts | 188 ++ .../src/tools/update-project.test.ts | 76 + packages/mcp-server/src/tools/whoami.test.ts | 22 + pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + 32 files changed, 1889 insertions(+), 1757 deletions(-) create mode 100644 docs/quality-checks.mdc delete mode 100644 packages/mcp-server/src/tools.test.ts create mode 100644 packages/mcp-server/src/tools/analyze-issue-with-seer.test.ts create mode 100644 packages/mcp-server/src/tools/create-dsn.test.ts create mode 100644 packages/mcp-server/src/tools/create-project.test.ts create mode 100644 packages/mcp-server/src/tools/create-team.test.ts create mode 100644 packages/mcp-server/src/tools/find-dsns.test.ts create mode 100644 packages/mcp-server/src/tools/find-errors.test.ts create mode 100644 packages/mcp-server/src/tools/find-issues.test.ts create mode 100644 packages/mcp-server/src/tools/find-organizations.test.ts create mode 100644 packages/mcp-server/src/tools/find-projects.test.ts create mode 100644 packages/mcp-server/src/tools/find-releases.test.ts create mode 100644 packages/mcp-server/src/tools/find-tags.test.ts create mode 100644 packages/mcp-server/src/tools/find-teams.test.ts create mode 100644 packages/mcp-server/src/tools/find-transactions.test.ts create mode 100644 packages/mcp-server/src/tools/get-doc.test.ts create mode 100644 packages/mcp-server/src/tools/get-issue-details.test.ts create mode 100644 packages/mcp-server/src/tools/search-docs.test.ts create mode 100644 packages/mcp-server/src/tools/update-issue.test.ts create mode 100644 packages/mcp-server/src/tools/update-project.test.ts create mode 100644 packages/mcp-server/src/tools/whoami.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ac65cbfd4..ee85c7663 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,117 +1,107 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code when working with this repository. -## Project Overview +## Repository Structure -Sentry MCP is a Model Context Protocol server that provides access to Sentry's functionality through tools, prompts, and resources. - -- Our project in sentry is 'sentry/mcp-server' - -## Documentation - -All documentation is in the `docs/` directory: - -### Core MCP Server - -- `architecture.mdc` - MCP server architecture (NOT the web app) -- `common-patterns.mdc` - Reusable code patterns -- `quality-checks.mdc` - Required quality checks - -### Implementation Guides - -- `adding-tools.mdc` - Adding new MCP tools -- `adding-prompts.mdc` - Adding new MCP prompts -- `adding-resources.mdc` - Adding new MCP resources - -### Technical References - -- `api-patterns.mdc` - Sentry API client usage -- `testing.mdc` - Testing strategies -- `monitoring.mdc` - Observability patterns -- `security.mdc` - Authentication and security - -### Cloudflare Web App (Separate Project) +``` +sentry-mcp/ +├── packages/ +│ ├── mcp-server/ # Main MCP server (tools, prompts, resources) +│ │ ├── src/ +│ │ │ ├── tools/ # 19 individual tool modules + utils +│ │ │ ├── prompts.ts # MCP prompts +│ │ │ ├── resources.ts # MCP resources +│ │ │ ├── server.ts # MCP server configuration +│ │ │ ├── api-client/ # Sentry API client +│ │ │ └── internal/ # Shared utilities +│ │ └── scripts/ # Build scripts (tool definitions generation) +│ ├── mcp-cloudflare/ # Cloudflare Worker chat application +│ │ ├── src/ +│ │ │ ├── client/ # React frontend +│ │ │ └── server/ # Worker API routes +│ │ └── components.json # Shadcn/ui config +│ ├── mcp-server-evals/ # AI evaluation tests +│ ├── mcp-server-mocks/ # MSW mocks for testing +│ ├── mcp-server-tsconfig/ # Shared TypeScript configs +│ └── mcp-test-client/ # MCP client for testing +└── docs/ # All documentation + ├── cloudflare/ # Web app docs + └── llms/ # LLM-specific docs +``` -- `cloudflare/` - Documentation for the web chat application -- This is a SEPARATE application that uses MCP, not part of MCP itself +## Core Components Impact Analysis -You should ALWAYS update docs when they are inaccurate or you have learned new relevant information which would add clarity that is otherwise missing. +When making changes, consider these areas: -## Documentation Maintenance +### MCP Server (`packages/mcp-server/`) +- **Tools** (19 modules): Query, create, update Sentry resources +- **Prompts**: Help text and guidance for LLMs +- **Resources**: Static documentation and references +- **API Client**: Sentry API integration layer +- **Server**: MCP protocol handler and error formatting -- **Keep CLAUDE.md and cursor.mdc concise**: These files are navigation aids, not comprehensive docs -- **Reference, don't duplicate**: Point to `docs/` files instead of repeating content -- **Update referenced docs first**: When making changes, update the actual documentation before updating references -- **Avoid redundancy**: Check existing docs before creating new ones (see `docs/llms/documentation-style-guide.mdc`) +### Cloudflare Web App (`packages/mcp-cloudflare/`) +- **Client**: React-based chat interface with UI components +- **Server**: Worker API routes for search, auth, MCP communication +- **Integration**: Uses MCP server for tool execution -## Tool Count Limits +### Testing Infrastructure +- **Unit Tests**: Co-located with each component +- **Mocks**: Realistic API responses in `mcp-server-mocks/` +- **Evaluations**: AI-driven integration tests in `mcp-server-evals/` +- **Test Client**: Interactive MCP testing in `mcp-test-client/` -**IMPORTANT**: AI agents have a hard cap of 45 total tools. Sentry MCP must: -- Target ~20 tools (current best practice) -- Never exceed 25 tools (absolute maximum) -- This limit exists in Cursor and possibly other tools +### Build System +- **Tool Definitions**: Auto-generated JSON schemas for client consumption +- **TypeScript Config**: Shared configurations in `mcp-server-tsconfig/` +- **Packaging**: Multiple package coordination -## Critical Quality Checks +## 🔴 CRITICAL: Pre-Development Requirements -**After ANY code changes, you MUST run:** +**MANDATORY READING before ANY code changes:** -```bash -pnpm -w run lint:fix # Fix linting issues -pnpm tsc # Check TypeScript types -pnpm test # Run all tests -``` +### MCP Component Development +- **Tools**: MUST read `docs/adding-tools.mdc` +- **Prompts**: MUST read `docs/adding-prompts.mdc` +- **Resources**: MUST read `docs/adding-resources.mdc` +- **Testing**: MUST read `docs/testing.mdc` for all components -**DO NOT proceed if any check fails.** +### Code Changes +- MUST read `docs/common-patterns.mdc` for established patterns +- MUST read `docs/api-patterns.mdc` for API usage +- MUST verify component count limits (tools: ~20 max, prompts/resources: reasonable limits) -## Essential Commands +## Documentation Maintenance Requirements -```bash -# Development -pnpm dev # Start all dev servers -pnpm build # Build all packages -pnpm inspector # Test tools interactively +**MANDATORY: Documentation MUST be updated when making code changes** +- Documentation updates are not optional - they are part of completing any task +- CLAUDE.md ↔ cursor.mdc must stay synchronized +- Update relevant docs for tools, prompts, resources, API patterns, or architecture changes -# Testing -pnpm test # Unit tests -pnpm eval # Evaluation tests (needs OPENAI_API_KEY) +## Quality Checks -# MCP Client Testing -pnpm start:client # Interactive MCP client (needs ANTHROPIC_API_KEY) -pnpm start:client:local # Use local stdio server +**MUST run after ANY code changes:** See `docs/quality-checks.mdc` -# Deployment -pnpm deploy # Deploy to Cloudflare -``` +## Component Limits -## Quick Start +**IMPORTANT**: +- **Tools**: Target ~20, never exceed 25 (AI agent hard limitations) +- **Prompts**: Keep reasonable, well-documented +- **Resources**: Keep reasonable, well-documented -1. Install dependencies: `pnpm install` -2. For local testing: `pnpm start:stdio --access-token=` -3. For development: `pnpm dev` -4. For client testing: `pnpm start:client` (requires ANTHROPIC_API_KEY) +## Documentation Directory -## Claude Code-Specific Notes +- `docs/adding-tools.mdc` - Tool development +- `docs/adding-prompts.mdc` - Prompt development +- `docs/adding-resources.mdc` - Resource development +- `docs/testing.mdc` - Testing requirements for all components +- `docs/common-patterns.mdc` - Code patterns +- `docs/api-patterns.mdc` - API usage +- `docs/architecture.mdc` - System design +- `docs/quality-checks.mdc` - Required quality checks -When using Claude Code's MCP integration: +## Claude Code Notes -- The server runs via stdio transport +- Server runs via stdio transport - Authentication uses access tokens (not OAuth) -- See integration docs in the web UI for setup instructions - -## Environment Variables - -See specific guides for required environment variables: - -- Cloudflare web app: `docs/cloudflare/deployment.md` -- Evaluation tests: `.env.example` -- Local development: Use command-line args -- MCP Client: - - `ANTHROPIC_API_KEY` - Required for AI agent - - `SENTRY_ACCESS_TOKEN` - Required for local stdio mode - - `MCP_HOST` - Optional, defaults to https://mcp.sentry.dev (used by search_docs tool) - -## References - -- MCP Protocol: https://modelcontextprotocol.io -- Sentry API: https://docs.sentry.io/api/ diff --git a/docs/adding-tools.mdc b/docs/adding-tools.mdc index 508e55aea..00a5f9a6b 100644 --- a/docs/adding-tools.mdc +++ b/docs/adding-tools.mdc @@ -22,19 +22,23 @@ Before adding a new tool, consider if it could be: ## Tool Structure Each tool consists of: -1. **Definition** - Schema in `toolDefinitions.ts` -2. **Handler** - Logic in `tools.ts` -3. **Tests** - Unit tests in `tools.test.ts` -4. **Mocks** - API responses in `mcp-server-mocks` -5. **Evals** - Integration tests (use sparingly) +1. **Tool Module** - Single file in `src/tools/your-tool-name.ts` with definition and handler +2. **Tests** - Unit tests in `src/tools/your-tool-name.test.ts` +3. **Mocks** - API responses in `mcp-server-mocks` +4. **Evals** - Integration tests (use sparingly) -## Step 1: Define the Tool +## Step 1: Create the Tool Module -Add to `packages/mcp-server/src/toolDefinitions.ts`: +Create `packages/mcp-server/src/tools/your-tool-name.ts`: ```typescript -{ - name: "your_tool_name" as const, +import { z } from "zod"; +import { defineTool } from "./utils/defineTool"; +import { apiServiceFromContext } from "./utils/api-utils"; +import type { ServerContext } from "../types"; + +export default defineTool({ + name: "your_tool_name", description: [ "One-line summary.", "", @@ -50,12 +54,15 @@ Add to `packages/mcp-server/src/toolDefinitions.ts`: "- Parameter interpretation hints", "", ].join("\n"), - paramsSchema: { - organizationSlug: ParamOrganizationSlug, - regionUrl: ParamRegionUrl.optional(), + inputSchema: { + organizationSlug: z.string().describe("The organization's slug"), + regionUrl: z.string().optional().describe("Optional region URL"), yourParam: z.string().describe("What values are expected"), }, -} + async handler(params, context: ServerContext) { + // Implementation here + }, +}); ``` ### Writing LLM-Friendly Descriptions @@ -68,16 +75,16 @@ Critical for LLM success: ## Step 2: Implement the Handler -Add to `packages/mcp-server/src/tools.ts`: +Add the handler implementation to your tool module: ```typescript -your_tool_name: async (context, params) => { +async handler(params, context: ServerContext) { // 1. Get API service const api = apiServiceFromContext(context, { regionUrl: params.regionUrl, }); - // 2. Validate inputs (see common-patterns.mdc) + // 2. Validate inputs (see common-patterns.mdc#error-handling) if (!params.organizationSlug) { throw new UserInputError( "Organization slug is required. Use find_organizations() to list." @@ -105,7 +112,7 @@ your_tool_name: async (context, params) => { output += "- Next tool to use: `related_tool(param='...')`\n"; return output; -}; +} ``` ### Response Formatting @@ -117,14 +124,23 @@ See `common-patterns.mdc#response-formatting` for: ## Step 3: Add Tests -Unit test in `tools.test.ts`: +Follow comprehensive testing patterns from `testing.mdc` for unit, integration, and evaluation tests. + +Create `packages/mcp-server/src/tools/your-tool-name.test.ts`: ```typescript +import { describe, it, expect } from "vitest"; +import yourToolName from "./your-tool-name.js"; + describe("your_tool_name", () => { it("returns formatted output", async () => { - const result = await TOOL_HANDLERS.your_tool_name( - mockContext, - { organizationSlug: "test-org", yourParam: "value" } + const result = await yourToolName.handler( + { organizationSlug: "test-org", yourParam: "value" }, + { + accessToken: "test-token", + userId: "1", + organizationSlug: null, + } ); expect(result).toMatchInlineSnapshot(` @@ -136,6 +152,12 @@ describe("your_tool_name", () => { }); ``` +**Testing Requirements:** +- Input validation (see `testing.mdc#testing-error-cases`) +- Error handling (use patterns from `common-patterns.mdc#error-handling`) +- Output formatting with snapshots +- API integration with MSW mocks + **After changing output, update snapshots:** ```bash cd packages/mcp-server diff --git a/docs/api-patterns.mdc b/docs/api-patterns.mdc index 9218943b8..824a3d56e 100644 --- a/docs/api-patterns.mdc +++ b/docs/api-patterns.mdc @@ -19,7 +19,7 @@ const api = new SentryApiService({ }); ``` -See: `packages/mcp-server/src/api-utils.ts` +See: `packages/mcp-server/src/api-utils.ts` and `adding-tools.mdc#step-2-implement-the-handler` for usage in tools. ### Common Operations @@ -83,6 +83,8 @@ See Zod patterns: `common-patterns.mdc#zod-schema-patterns` ### Type Safety +For testing API patterns, see `testing.mdc#mock-server-setup` + ```typescript // Derive types from schemas export type Issue = z.infer; diff --git a/docs/common-patterns.mdc b/docs/common-patterns.mdc index 8d67f3c16..ea7a1eb84 100644 --- a/docs/common-patterns.mdc +++ b/docs/common-patterns.mdc @@ -85,6 +85,8 @@ export type ToolParams = z.infer; ## Testing Patterns +For comprehensive testing guidance, see `testing.mdc` and `adding-tools.mdc#step-3-add-tests`. + ### Unit Test Structure ```typescript @@ -124,6 +126,8 @@ See: `packages/mcp-server/src/test-utils/setup.ts` ## API Patterns +For complete API usage patterns, see `api-patterns.mdc`. + ### Service Creation ```typescript diff --git a/docs/cursor.mdc b/docs/cursor.mdc index cc395a1e0..18b158b15 100644 --- a/docs/cursor.mdc +++ b/docs/cursor.mdc @@ -11,6 +11,45 @@ This file provides instructions for Cursor IDE when working with the Sentry MCP Sentry MCP is a Model Context Protocol server that provides access to Sentry's functionality through tools, prompts, and resources. +## 🔴 CRITICAL: Pre-Development Requirements + +**MANDATORY READING before code changes:** + +### Tool Development +- MUST read `docs/adding-tools.mdc` before creating/modifying any tool +- MUST read `docs/testing.mdc` for testing requirements +- MUST verify tool count limits (target ~20, max 25) + +### Code Changes +- MUST read `docs/common-patterns.mdc` for established patterns +- MUST read `docs/api-patterns.mdc` for API usage + +## Documentation Maintenance Requirements + +**MANDATORY: Documentation MUST be updated when making code changes** + +### When Documentation MUST Be Updated +- **Adding new tools**: Update `docs/adding-tools.mdc` if new patterns emerge +- **Changing testing approaches**: Update `docs/testing.mdc` immediately +- **Modifying API patterns**: Update `docs/api-patterns.mdc` with new patterns +- **Adding common patterns**: Update `docs/common-patterns.mdc` immediately +- **Changing architecture**: Update `docs/architecture.mdc` + +### Critical Sync Requirements +- **CLAUDE.md ↔ cursor.mdc**: These files MUST stay synchronized +- **When updating CLAUDE.md**: Also update `cursor.mdc` with equivalent guidance +- **When updating cursor.mdc**: Also update `CLAUDE.md` with equivalent guidance +- Both files serve the same purpose for different tools (Claude Code vs Cursor IDE) + +### Documentation Update Process +1. **Identify affected docs** while implementing changes +2. **Update documentation in the same session** as code changes +3. **Verify cross-references** remain accurate +4. **Ensure CLAUDE.md ↔ cursor.mdc sync** is maintained +5. **Add examples** for new patterns introduced + +**Documentation updates are not optional - they are part of completing any task.** + ## Documentation All documentation is in the `docs/` directory: @@ -60,6 +99,23 @@ pnpm test # Run all tests **DO NOT proceed if any check fails.** +## Tool Testing Requirements + +**ALL tools MUST have comprehensive tests that verify:** + +- **Input validation** - Required/optional parameters, type checking, edge cases +- **Output formatting** - Markdown structure, content accuracy, error messages +- **API integration** - Mock server responses, error handling, parameter passing +- **Snapshot testing** - Use inline snapshots to verify formatted output + +**Required test patterns:** +- Unit tests in individual `{tool-name}.test.ts` files using Vitest and MSW mocks +- Input/output validation with inline snapshots +- Error case testing (API failures, invalid params) +- Mock server setup in `packages/mcp-server-mocks` + +See `docs/testing.mdc` for detailed testing patterns and `docs/adding-tools.mdc` for the testing workflow. + ## Essential Commands ```bash @@ -96,6 +152,61 @@ See specific guides for required environment variables: - Evaluation tests: `.env.example` - Local development: Use command-line args +## Repository Structure + +``` +sentry-mcp/ +├── packages/ +│ ├── mcp-server/ # Main MCP server (tools, prompts, resources) +│ │ ├── src/ +│ │ │ ├── tools/ # 19 individual tool modules + utils +│ │ │ ├── prompts.ts # MCP prompts +│ │ │ ├── resources.ts # MCP resources +│ │ │ ├── server.ts # MCP server configuration +│ │ │ ├── api-client/ # Sentry API client +│ │ │ └── internal/ # Shared utilities +│ │ └── scripts/ # Build scripts (tool definitions generation) +│ ├── mcp-cloudflare/ # Cloudflare Worker chat application +│ │ ├── src/ +│ │ │ ├── client/ # React frontend +│ │ │ └── server/ # Worker API routes +│ │ └── components.json # Shadcn/ui config +│ ├── mcp-server-evals/ # AI evaluation tests +│ ├── mcp-server-mocks/ # MSW mocks for testing +│ ├── mcp-server-tsconfig/ # Shared TypeScript configs +│ └── mcp-test-client/ # MCP client for testing +└── docs/ # All documentation + ├── cloudflare/ # Web app docs + └── llms/ # LLM-specific docs +``` + +## Core Components Impact Analysis + +When making changes, consider these component interactions: + +### MCP Server (`packages/mcp-server/`) +- **Tools** (19 modules): Query, create, update Sentry resources +- **Prompts**: Help text and guidance for LLMs +- **Resources**: Static documentation and references +- **API Client**: Sentry API integration layer +- **Server**: MCP protocol handler and error formatting + +### Cloudflare Web App (`packages/mcp-cloudflare/`) +- **Client**: React-based chat interface with UI components +- **Server**: Worker API routes for search, auth, MCP communication +- **Integration**: Uses MCP server for tool execution + +### Testing Infrastructure +- **Unit Tests**: Co-located with each component +- **Mocks**: Realistic API responses in `mcp-server-mocks/` +- **Evaluations**: AI-driven integration tests in `mcp-server-evals/` +- **Test Client**: Interactive MCP testing in `mcp-test-client/` + +### Build & Deployment +- **Tool Definitions**: Auto-generated JSON schemas for client consumption +- **TypeScript Config**: Shared configurations in `mcp-server-tsconfig/` +- **Packaging**: Multiple package coordination + ## References - MCP Protocol: https://modelcontextprotocol.io diff --git a/docs/quality-checks.mdc b/docs/quality-checks.mdc new file mode 100644 index 000000000..2a30f6fb6 --- /dev/null +++ b/docs/quality-checks.mdc @@ -0,0 +1,78 @@ +# Quality Checks + +Required quality checks that MUST pass before completing any code changes. + +## Critical Quality Checks + +**After ANY code changes, you MUST run:** + +```bash +pnpm -w run lint:fix # Fix linting issues +pnpm tsc --noEmit # Check TypeScript types +pnpm test # Run all tests +``` + +**DO NOT proceed if any check fails.** + +## Tool Testing Requirements + +**ALL tools MUST have comprehensive tests that verify:** + +- **Input validation** - Required/optional parameters, type checking, edge cases +- **Output formatting** - Markdown structure, content accuracy, error messages +- **API integration** - Mock server responses, error handling, parameter passing +- **Snapshot testing** - Use inline snapshots to verify formatted output + +**Required test patterns:** +- Unit tests in individual `{tool-name}.test.ts` files using Vitest and MSW mocks +- Input/output validation with inline snapshots +- Error case testing (API failures, invalid params) +- Mock server setup in `packages/mcp-server-mocks` + +See `docs/testing.mdc` for detailed testing patterns and `docs/adding-tools.mdc` for the testing workflow. + +## Tool Count Limits + +**IMPORTANT**: AI agents have a hard cap of 45 total tools. Sentry MCP must: +- Target ~20 tools (current best practice) +- Never exceed 25 tools (absolute maximum) +- This limit exists in Cursor and possibly other tools + +**Current status**: 19 tools (within target range) + +## Build Verification + +Ensure the build process works correctly: + +```bash +npm run build # Build all packages +npm run generate-tool-definitions # Generate tool definitions +``` + +Tool definitions must generate without errors for client consumption. + +## Code Quality Standards + +- **TypeScript strict mode** - All code must compile without errors +- **Linting compliance** - Follow established code style patterns +- **Test coverage** - All new tools must have comprehensive tests +- **Error handling** - Use patterns from `common-patterns.mdc#error-handling` +- **API patterns** - Follow patterns from `api-patterns.mdc` + +## Pre-Commit Checklist + +Before completing any task: + +- [ ] All quality checks pass (`pnpm -w run lint:fix`, `pnpm tsc --noEmit`, `pnpm test`) +- [ ] Tool count within limits (≤20 target, ≤25 absolute max) +- [ ] New tools have comprehensive tests +- [ ] Build process generates tool definitions successfully +- [ ] Documentation updated if patterns changed +- [ ] CLAUDE.md ↔ cursor.mdc sync maintained (if applicable) + +## References + +- Testing patterns: `testing.mdc` +- Tool development: `adding-tools.mdc` +- Code patterns: `common-patterns.mdc` +- API usage: `api-patterns.mdc` \ No newline at end of file diff --git a/docs/testing.mdc b/docs/testing.mdc index 4c5e3554f..70304bb9a 100644 --- a/docs/testing.mdc +++ b/docs/testing.mdc @@ -24,6 +24,8 @@ Real-world scenarios with LLM: ## Unit Testing Patterns +See `adding-tools.mdc#step-3-add-tests` for the complete tool testing workflow. + ### Basic Test Structure ```typescript @@ -43,6 +45,8 @@ describe("tool_name", () => { }); ``` +**NOTE**: Follow error handling patterns from `common-patterns.mdc#error-handling` when testing error cases. + ### Testing Error Cases ```typescript @@ -66,6 +70,8 @@ it("handles API errors gracefully", async () => { ## Mock Server Setup +Use MSW patterns from `api-patterns.mdc#mock-patterns` for API mocking. + ### Test Configuration ```typescript diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 5e0e42a85..0abe33d59 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -88,7 +88,8 @@ "devDependencies": { "@sentry/mcp-server-mocks": "workspace:*", "@sentry/mcp-server-tsconfig": "workspace:*", - "msw": "catalog:" + "msw": "catalog:", + "zod-to-json-schema": "catalog:" }, "dependencies": { "@modelcontextprotocol/sdk": "catalog:", diff --git a/packages/mcp-server/scripts/generate-tool-definitions.ts b/packages/mcp-server/scripts/generate-tool-definitions.ts index 1f4273830..7e52405c3 100644 --- a/packages/mcp-server/scripts/generate-tool-definitions.ts +++ b/packages/mcp-server/scripts/generate-tool-definitions.ts @@ -2,9 +2,8 @@ /** * Generate tool definitions JSON file for client consumption. * - * This script imports all tools from src/tools/index and extracts their - * name, description, and parameter schema descriptions to generate a - * toolDefinitions.json file in the dist directory. + * This script imports all tools from src/tools/index and exports their + * definitions (name, description, inputSchema) with Zod schemas converted to JSON Schema. * * This file is used by the mcp-cloudflare client to display tool documentation * without importing server-side code that has Node.js dependencies. @@ -13,7 +12,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import type { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { z } from "zod"; // Get the directory of this script const __filename = fileURLToPath(import.meta.url); @@ -22,123 +22,33 @@ const __dirname = path.dirname(__filename); // Import tools from the source directory const tools = await import("../src/tools/index.js"); -interface ToolDefinition { - name: string; - description: string; - inputSchema: Record< - string, - { - description: string; - required: boolean; - type?: string; - } - >; -} - /** - * Extract schema descriptions from Zod schemas. - * - * This function recursively extracts description metadata from Zod schemas, - * handling optional schemas and nested types. + * Convert Zod schema object to JSON Schema */ -function extractSchemaDescriptions( - schema: Record, -): Record { - if (!schema || typeof schema !== "object") { +function convertInputSchemaToJsonSchema(inputSchema: Record) { + if (!inputSchema || Object.keys(inputSchema).length === 0) { return {}; } - return Object.fromEntries( - Object.entries(schema).map(([key, zodSchema]) => { - let description = ""; - let required = true; - let type: string | undefined; - - if (zodSchema && typeof zodSchema === "object") { - // Type assertion for Zod schema shape - const schemaObj = zodSchema as { - description?: string; - _def?: { - innerType?: { - description?: string; - typeName?: string; - }; - typeName?: string; - }; - isOptional?: () => boolean; - }; - - // Extract description from the schema - description = schemaObj.description || ""; - - // Check if this is an optional schema - if (schemaObj._def) { - // For optional schemas, check the inner type - if (schemaObj._def.innerType) { - description = - description || schemaObj._def.innerType.description || ""; - required = false; - type = getZodTypeName(schemaObj._def.innerType.typeName); - } else { - type = getZodTypeName(schemaObj._def.typeName); - } - } - - // Some schemas might have isOptional method - if (typeof schemaObj.isOptional === "function") { - try { - required = !schemaObj.isOptional(); - } catch { - // If isOptional throws, assume required - required = true; - } - } - } - - return [ - key, - { - description, - required, - ...(type && { type }), - }, - ]; - }), - ); -} + // Convert the inputSchema object to a Zod object schema, then to JSON Schema + const zodObjectSchema = z.object(inputSchema); + const jsonSchema = zodToJsonSchema(zodObjectSchema, { + name: "ToolInputSchema", + $refStrategy: "none", // Don't use $ref for cleaner output + }); -/** - * Convert Zod type names to more readable type names. - */ -function getZodTypeName(typeName?: string): string | undefined { - if (!typeName) return undefined; - - switch (typeName) { - case "ZodString": - return "string"; - case "ZodNumber": - return "number"; - case "ZodBoolean": - return "boolean"; - case "ZodArray": - return "array"; - case "ZodObject": - return "object"; - case "ZodEnum": - return "enum"; - case "ZodUnion": - return "union"; - case "ZodLiteral": - return "literal"; - default: - return typeName; + // If there are definitions, extract from there, otherwise use direct properties + if (jsonSchema.definitions?.ToolInputSchema?.properties) { + return jsonSchema.definitions.ToolInputSchema.properties; } + + return jsonSchema.properties || {}; } /** * Generate tool definitions from imported tools. */ -function generateToolDefinitions(): ToolDefinition[] { +function generateToolDefinitions() { const toolsDefault = tools.default; if (!toolsDefault || typeof toolsDefault !== "object") { @@ -153,17 +63,22 @@ function generateToolDefinitions(): ToolDefinition[] { const toolObj = tool as { name: string; description: string; - inputSchema: Record; + inputSchema: Record; }; if (!toolObj.name || !toolObj.description) { throw new Error(`Tool ${key} is missing name or description`); } + // Convert Zod schemas to JSON Schema + const inputSchema = convertInputSchemaToJsonSchema( + toolObj.inputSchema || {}, + ); + return { name: toolObj.name, description: toolObj.description, - inputSchema: extractSchemaDescriptions(toolObj.inputSchema || {}), + inputSchema, }; }); } @@ -193,11 +108,7 @@ async function main() { const dtsContent = `declare const toolDefinitions: Array<{ name: string; description: string; - inputSchema: Record; + inputSchema: Record; }>; export default toolDefinitions;`; fs.writeFileSync(dtsPath, dtsContent); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 04e4219e3..6fe3e9d93 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -361,7 +361,27 @@ export async function configureServer({ span.setStatus({ code: 2, // error }); - throw error; + + // CRITICAL: Tool errors MUST be returned as formatted text responses, + // NOT thrown as exceptions. This ensures consistent error handling + // and prevents the MCP client from receiving raw error objects. + // + // The logAndFormatError function provides user-friendly error messages + // with appropriate formatting for different error types: + // - UserInputError: Clear guidance for fixing input problems + // - ApiError: HTTP status context with helpful messaging + // - System errors: Sentry event IDs for debugging + // + // DO NOT change this to throw error - it breaks error handling! + return { + content: [ + { + type: "text" as const, + text: await logAndFormatError(error), + }, + ], + isError: true, + }; } }, ); diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts deleted file mode 100644 index 46de2ad28..000000000 --- a/packages/mcp-server/src/tools.test.ts +++ /dev/null @@ -1,1525 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import tools from "./tools/index.js"; - -// Create a compatibility wrapper for the old TOOL_HANDLERS structure -const TOOL_HANDLERS = Object.fromEntries( - Object.entries(tools).map(([key, tool]) => [ - key, - async (context: any, params: any) => { - return tool.handler(params, context); - }, - ]), -); - -describe("whoami", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.whoami; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - {}, - ); - expect(result).toMatchInlineSnapshot( - ` - "You are authenticated as John Doe (john.doe@example.com). - - Your Sentry User ID is 1." - `, - ); - }); -}); - -describe("find_organizations", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.find_organizations; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - {}, - ); - expect(result).toMatchInlineSnapshot(` - "# Organizations - - ## **sentry-mcp-evals** - - **Web URL:** https://sentry.io/sentry-mcp-evals - **Region URL:** https://us.sentry.io - - # Using this information - - - The organization's name is the identifier for the organization, and is used in many tools for \`organizationSlug\`. - - If a tool supports passing in the \`regionUrl\`, you MUST pass in the correct value shown above for each organization. - - For Sentry's Cloud Service (sentry.io), always use the regionUrl to ensure requests go to the correct region. - " - `); - }); - - it("handles empty regionUrl parameter", async () => { - const tool = TOOL_HANDLERS.find_organizations; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - {}, - ); - expect(result).toContain("Organizations"); - }); - - it("handles undefined regionUrl parameter", async () => { - const tool = TOOL_HANDLERS.find_organizations; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - {}, - ); - expect(result).toContain("Organizations"); - }); -}); - -describe("find_teams", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.find_teams; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Teams in **sentry-mcp-evals** - - - the-goats - " - `); - }); -}); - -describe("find_projects", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.find_projects; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Projects in **sentry-mcp-evals** - - - **cloudflare-mcp** - " - `); - }); -}); - -describe("find_issues", () => { - it("serializes with project", async () => { - const tool = TOOL_HANDLERS.find_issues; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: "cloudflare-mcp", - query: undefined, - sortBy: "last_seen", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Issues in **sentry-mcp-evals/cloudflare-mcp** - - ## CLOUDFLARE-MCP-41 - - **Description**: Error: Tool list_organizations is already registered - **Culprit**: Object.fetch(index) - **First Seen**: 2025-04-03T22:51:19.403Z - **Last Seen**: 2025-04-12T11:34:11.000Z - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - - ## CLOUDFLARE-MCP-42 - - **Description**: Error: Tool list_issues is already registered - **Culprit**: Object.fetch(index) - **First Seen**: 2025-04-11T22:51:19.403Z - **Last Seen**: 2025-04-12T11:34:11.000Z - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-42 - - # Using this information - - - You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged. - - You can get more details about a specific issue by using the tool: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId=)\` - " - `); - }); - - it("serializes without project", async () => { - const tool = TOOL_HANDLERS.find_issues; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: undefined, - query: undefined, - sortBy: "last_seen", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Issues in **sentry-mcp-evals** - - ## CLOUDFLARE-MCP-41 - - **Description**: Error: Tool list_organizations is already registered - **Culprit**: Object.fetch(index) - **First Seen**: 2025-04-03T22:51:19.403Z - **Last Seen**: 2025-04-12T11:34:11.000Z - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - - ## CLOUDFLARE-MCP-42 - - **Description**: Error: Tool list_issues is already registered - **Culprit**: Object.fetch(index) - **First Seen**: 2025-04-11T22:51:19.403Z - **Last Seen**: 2025-04-12T11:34:11.000Z - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-42 - - # Using this information - - - You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged. - - You can get more details about a specific issue by using the tool: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId=)\` - " - `); - }); -}); - -describe("find_releases", () => { - it("works without project", async () => { - const tool = TOOL_HANDLERS.find_releases; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: undefined, - regionUrl: undefined, - query: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Releases in **sentry-mcp-evals** - - ## 8ce89484-0fec-4913-a2cd-e8e2d41dee36 - - **Created**: 2025-04-13T19:54:21.764Z - **First Event**: 2025-04-13T19:54:21.000Z - **Last Event**: 2025-04-13T20:28:23.000Z - **New Issues**: 0 - **Projects**: cloudflare-mcp - - # Using this information - - - You can reference the Release version in commit messages or documentation. - - You can search for issues in a specific release using the \`find_errors()\` tool with the query \`release:8ce89484-0fec-4913-a2cd-e8e2d41dee36\`. - " - `); - }); - it("works with project", async () => { - const tool = TOOL_HANDLERS.find_releases; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: "cloudflare-mcp", - regionUrl: undefined, - query: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Releases in **sentry-mcp-evals/cloudflare-mcp** - - ## 8ce89484-0fec-4913-a2cd-e8e2d41dee36 - - **Created**: 2025-04-13T19:54:21.764Z - **First Event**: 2025-04-13T19:54:21.000Z - **Last Event**: 2025-04-13T20:28:23.000Z - **New Issues**: 0 - **Projects**: cloudflare-mcp - - # Using this information - - - You can reference the Release version in commit messages or documentation. - - You can search for issues in a specific release using the \`find_errors()\` tool with the query \`release:8ce89484-0fec-4913-a2cd-e8e2d41dee36\`. - " - `); - }); -}); - -describe("find_tags", () => { - it("works", async () => { - const tool = TOOL_HANDLERS.find_tags; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Tags in **sentry-mcp-evals** - - - transaction - - runtime.name - - level - - device - - os - - user - - runtime - - release - - url - - uptime_rule - - server_name - - browser - - os.name - - device.family - - replayId - - client_os.name - - environment - - service - - browser.name - - # Using this information - - - You can reference tags in the \`query\` parameter of various tools: \`tagName:tagValue\`. - " - `); - }); -}); - -describe("find_errors", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.find_errors; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: undefined, - filename: undefined, - transaction: undefined, - query: undefined, - sortBy: "count", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Errors in **sentry-mcp-evals** - - - ## CLOUDFLARE-MCP-41 - - **Description**: Error: Tool list_organizations is already registered - **Issue ID**: CLOUDFLARE-MCP-41 - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - **Project**: test-suite - **Last Seen**: 2025-04-07T12:23:39+00:00 - **Occurrences**: 2 - - # Using this information - - - You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged. - - You can get more details about an error by using the tool: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId=)\` - " - `); - }); -}); - -describe("find_transactions", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.find_transactions; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: undefined, - transaction: undefined, - query: undefined, - sortBy: "duration", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Transactions in **sentry-mcp-evals** - - - ## \`GET /trpc/bottleList\` - - **Span ID**: 07752c6aeb027c8f - **Trace ID**: 6a477f5b0f31ef7b6b9b5e1dea66c91d - **Span Operation**: http.server - **Span Description**: GET /trpc/bottleList - **Duration**: 12 - **Timestamp**: 2025-04-13T14:19:18+00:00 - **Project**: peated - **URL**: https://sentry-mcp-evals.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d - - ## \`GET /trpc/bottleList\` - - **Span ID**: 7ab5edf5b3ba42c9 - **Trace ID**: 54177131c7b192a446124daba3136045 - **Span Operation**: http.server - **Span Description**: GET /trpc/bottleList - **Duration**: 18 - **Timestamp**: 2025-04-13T14:19:17+00:00 - **Project**: peated - **URL**: https://sentry-mcp-evals.sentry.io/explore/traces/trace/54177131c7b192a446124daba3136045 - - " - `); - }); -}); - -describe("get_issue_details", () => { - it("serializes with issueId", async () => { - const tool = TOOL_HANDLERS.get_issue_details; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - eventId: undefined, - issueUrl: undefined, - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals** - - **Description**: Error: Tool list_organizations is already registered - **Culprit**: Object.fetch(index) - **First Seen**: 2025-04-03T22:51:19.403Z - **Last Seen**: 2025-04-12T11:34:11.000Z - **Occurrences**: 25 - **Users Impacted**: 1 - **Status**: unresolved - **Platform**: javascript - **Project**: CLOUDFLARE-MCP - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - - ## Event Details - - **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51 - **Occurred At**: 2025-04-08T21:15:04.000Z - - ### Error - - \`\`\` - Error: Tool list_organizations is already registered - \`\`\` - - **Stacktrace:** - \`\`\` - index.js:7809:27 - index.js:8029:24 (OAuthProviderImpl.fetch) - index.js:19631:28 (Object.fetch) - \`\`\` - - ### HTTP Request - - **Method:** GET - **URL:** https://mcp.sentry.dev/sse - - ### Additional Context - - These are additional context provided by the user when they're instrumenting their application. - - **cloud_resource** - cloud.provider: "cloudflare" - - **culture** - timezone: "Europe/London" - - **runtime** - name: "cloudflare" - - **trace** - trace_id: "3032af8bcdfe4423b937fc5c041d5d82" - span_id: "953da703d2a6f4c7" - status: "unknown" - client_sample_rate: 1 - sampled: true - - # Using this information - - - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged. - - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code. - " - `); - }); - - it("serializes with issueUrl", async () => { - const tool = TOOL_HANDLERS.get_issue_details; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: undefined, - issueId: undefined, - eventId: undefined, - issueUrl: "https://sentry-mcp-evals.sentry.io/issues/6507376925", - regionUrl: undefined, - }, - ); - - expect(result).toMatchInlineSnapshot(` - "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals** - - **Description**: Error: Tool list_organizations is already registered - **Culprit**: Object.fetch(index) - **First Seen**: 2025-04-03T22:51:19.403Z - **Last Seen**: 2025-04-12T11:34:11.000Z - **Occurrences**: 25 - **Users Impacted**: 1 - **Status**: unresolved - **Platform**: javascript - **Project**: CLOUDFLARE-MCP - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - - ## Event Details - - **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51 - **Occurred At**: 2025-04-08T21:15:04.000Z - - ### Error - - \`\`\` - Error: Tool list_organizations is already registered - \`\`\` - - **Stacktrace:** - \`\`\` - index.js:7809:27 - index.js:8029:24 (OAuthProviderImpl.fetch) - index.js:19631:28 (Object.fetch) - \`\`\` - - ### HTTP Request - - **Method:** GET - **URL:** https://mcp.sentry.dev/sse - - ### Additional Context - - These are additional context provided by the user when they're instrumenting their application. - - **cloud_resource** - cloud.provider: "cloudflare" - - **culture** - timezone: "Europe/London" - - **runtime** - name: "cloudflare" - - **trace** - trace_id: "3032af8bcdfe4423b937fc5c041d5d82" - span_id: "953da703d2a6f4c7" - status: "unknown" - client_sample_rate: 1 - sampled: true - - # Using this information - - - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged. - - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code. - " - `); - }); - it("serializes with eventId", async () => { - const tool = TOOL_HANDLERS.get_issue_details; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - issueId: undefined, - issueUrl: undefined, - eventId: "7ca573c0f4814912aaa9bdc77d1a7d51", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals** - - **Description**: Error: Tool list_organizations is already registered - **Culprit**: Object.fetch(index) - **First Seen**: 2025-04-03T22:51:19.403Z - **Last Seen**: 2025-04-12T11:34:11.000Z - **Occurrences**: 25 - **Users Impacted**: 1 - **Status**: unresolved - **Platform**: javascript - **Project**: CLOUDFLARE-MCP - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - - ## Event Details - - **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51 - **Occurred At**: 2025-04-08T21:15:04.000Z - - ### Error - - \`\`\` - Error: Tool list_organizations is already registered - \`\`\` - - **Stacktrace:** - \`\`\` - index.js:7809:27 - index.js:8029:24 (OAuthProviderImpl.fetch) - index.js:19631:28 (Object.fetch) - \`\`\` - - ### HTTP Request - - **Method:** GET - **URL:** https://mcp.sentry.dev/sse - - ### Additional Context - - These are additional context provided by the user when they're instrumenting their application. - - **cloud_resource** - cloud.provider: "cloudflare" - - **culture** - timezone: "Europe/London" - - **runtime** - name: "cloudflare" - - **trace** - trace_id: "3032af8bcdfe4423b937fc5c041d5d82" - span_id: "953da703d2a6f4c7" - status: "unknown" - client_sample_rate: 1 - sampled: true - - # Using this information - - - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged. - - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code. - " - `); - }); - - it("throws error for malformed regionUrl", async () => { - const tool = TOOL_HANDLERS.get_issue_details; - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - eventId: undefined, - issueUrl: undefined, - regionUrl: "https", - }, - ), - ).rejects.toThrow( - "Invalid regionUrl provided: https. Must be a valid URL.", - ); - }); -}); - -describe("create_team", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.create_team; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - name: "the-goats", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# New Team in **sentry-mcp-evals** - - **ID**: 4509109078196224 - **Slug**: the-goats - **Name**: the-goats - # Using this information - - - You should always inform the user of the Team Slug value. - " - `); - }); -}); - -describe("create_project", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.create_project; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - teamSlug: "the-goats", - name: "cloudflare-mcp", - platform: "node", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# New Project in **sentry-mcp-evals** - - **ID**: 4509109104082945 - **Slug**: cloudflare-mcp - **Name**: cloudflare-mcp - **SENTRY_DSN**: https://d20df0a1ab5031c7f3c7edca9c02814d@o4509106732793856.ingest.us.sentry.io/4509109104082945 - - # Using this information - - - You can reference the **SENTRY_DSN** value to initialize Sentry's SDKs. - - You should always inform the user of the **SENTRY_DSN** and Project Slug values. - " - `); - }); -}); - -describe("update_project", () => { - it("updates name and platform", async () => { - const tool = TOOL_HANDLERS.update_project; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: "cloudflare-mcp", - name: "New Project Name", - slug: undefined, - platform: "python", - teamSlug: undefined, - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Updated Project in **sentry-mcp-evals** - - **ID**: 4509109104082945 - **Slug**: cloudflare-mcp - **Name**: New Project Name - **Platform**: python - - ## Updates Applied - - Updated name to "New Project Name" - - Updated platform to "python" - - # Using this information - - - The project is now accessible at slug: \`cloudflare-mcp\` - " - `); - }); - - it("assigns project to new team", async () => { - const tool = TOOL_HANDLERS.update_project; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: "cloudflare-mcp", - name: undefined, - slug: undefined, - platform: undefined, - teamSlug: "backend-team", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Updated Project in **sentry-mcp-evals** - - **ID**: 4509106749636608 - **Slug**: cloudflare-mcp - **Name**: cloudflare-mcp - **Platform**: node - - ## Updates Applied - - Updated team assignment to "backend-team" - - # Using this information - - - The project is now accessible at slug: \`cloudflare-mcp\` - - The project is now assigned to the \`backend-team\` team - " - `); - }); -}); - -describe("create_dsn", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.create_dsn; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: "cloudflare-mcp", - name: "Default", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# New DSN in **sentry-mcp-evals/cloudflare-mcp** - - **DSN**: https://d20df0a1ab5031c7f3c7edca9c02814d@o4509106732793856.ingest.us.sentry.io/4509109104082945 - **Name**: Default - - # Using this information - - - The \`SENTRY_DSN\` value is a URL that you can use to initialize Sentry's SDKs. - " - `); - }); -}); - -describe("find_dsns", () => { - it("serializes", async () => { - const tool = TOOL_HANDLERS.find_dsns; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - projectSlug: "cloudflare-mcp", - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# DSNs in **sentry-mcp-evals/cloudflare-mcp** - - ## Default - **ID**: d20df0a1ab5031c7f3c7edca9c02814d - **DSN**: https://d20df0a1ab5031c7f3c7edca9c02814d@o4509106732793856.ingest.us.sentry.io/4509109104082945 - - # Using this information - - - The \`SENTRY_DSN\` value is a URL that you can use to initialize Sentry's SDKs. - " - `); - }); -}); - -describe("analyze_issue_with_seer", () => { - it("handles combined workflow", async () => { - // This test validates the tool works correctly - // In a real scenario, it would poll multiple times, but for testing - // we'll validate the key outputs are present - const tool = TOOL_HANDLERS.analyze_issue_with_seer; - - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-45", - issueUrl: undefined, - regionUrl: undefined, - instruction: undefined, - }, - ); - - expect(result).toContain("# Seer AI Analysis for Issue CLOUDFLARE-MCP-45"); - expect(result).toContain("Found existing analysis (Run ID: 13)"); - expect(result).toContain("## Analysis Complete"); - expect(result).toContain("## 1. **Root Cause Analysis**"); - expect(result).toContain("The analysis has completed successfully."); - }); -}); - -describe("update_issue", () => { - it("updates issue status", async () => { - const tool = TOOL_HANDLERS.update_issue; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Issue CLOUDFLARE-MCP-41 Updated in **sentry-mcp-evals** - - **Issue**: Error: Tool list_organizations is already registered - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - - ## Changes Made - - **Status**: unresolved → **resolved** - - ## Current Status - - **Status**: resolved - **Assigned To**: Unassigned - - # Using this information - - - The issue has been successfully updated in Sentry - - You can view the issue details using: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId="CLOUDFLARE-MCP-41")\` - - The issue is now marked as resolved and will no longer generate alerts - " - `); - }); - - it("updates issue assignment", async () => { - const tool = TOOL_HANDLERS.update_issue; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: undefined, - assignedTo: "john.doe", - issueUrl: undefined, - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Issue CLOUDFLARE-MCP-41 Updated in **sentry-mcp-evals** - - **Issue**: Error: Tool list_organizations is already registered - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - - ## Changes Made - - **Assigned To**: Unassigned → **john.doe** - - ## Current Status - - **Status**: unresolved - **Assigned To**: john.doe - - # Using this information - - - The issue has been successfully updated in Sentry - - You can view the issue details using: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId="CLOUDFLARE-MCP-41")\` - " - `); - }); - - it("updates both status and assignment", async () => { - const tool = TOOL_HANDLERS.update_issue; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - assignedTo: "me", - issueUrl: undefined, - regionUrl: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Issue CLOUDFLARE-MCP-41 Updated in **sentry-mcp-evals** - - **Issue**: Error: Tool list_organizations is already registered - **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 - - ## Changes Made - - **Status**: unresolved → **resolved** - **Assigned To**: Unassigned → **You** - - ## Current Status - - **Status**: resolved - **Assigned To**: me - - # Using this information - - - The issue has been successfully updated in Sentry - - You can view the issue details using: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId="CLOUDFLARE-MCP-41")\` - - The issue is now marked as resolved and will no longer generate alerts - " - `); - }); - - it("validates required parameters", async () => { - const tool = TOOL_HANDLERS.update_issue; - - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: undefined, - issueId: undefined, - status: undefined, - assignedTo: undefined, - issueUrl: undefined, - regionUrl: undefined, - }, - ), - ).rejects.toThrow("Either `issueId` or `issueUrl` must be provided"); - }); - - it("validates organization slug when using issueId", async () => { - const tool = TOOL_HANDLERS.update_issue; - - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: undefined, - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: undefined, - }, - ), - ).rejects.toThrow( - "`organizationSlug` is required when providing `issueId`", - ); - }); - - it("validates update parameters", async () => { - const tool = TOOL_HANDLERS.update_issue; - - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: undefined, - assignedTo: undefined, - issueUrl: undefined, - regionUrl: undefined, - }, - ), - ).rejects.toThrow( - "At least one of `status` or `assignedTo` must be provided to update the issue", - ); - }); -}); - -describe("search_docs", () => { - // Note: Query validation (empty, too short, too long) is now handled by Zod schema - // These validation tests are no longer needed as they test framework behavior, not our tool logic - - it("returns results from the API", async () => { - const tool = TOOL_HANDLERS.search_docs; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - host: "https://mcp.sentry.dev", - }, - { - query: "How do I configure rate limiting?", - maxResults: 5, - guide: undefined, - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Documentation Search Results - - **Query**: "How do I configure rate limiting?" - - Found 2 matches - - These are just snippets. Use \`get_doc(path='...')\` to fetch the full content. - - ## 1. https://docs.sentry.io/product/rate-limiting - - **Path**: product/rate-limiting.md - **Relevance**: 95.0% - - **Matching Context** - > Learn how to configure rate limiting in Sentry to prevent quota exhaustion and control event ingestion. - - ## 2. https://docs.sentry.io/product/accounts/quotas/spike-protection - - **Path**: product/accounts/quotas/spike-protection.md - **Relevance**: 87.0% - - **Matching Context** - > Spike protection helps prevent unexpected spikes in event volume from consuming your quota. - - " - `); - }); - - it("handles API errors", async () => { - const tool = TOOL_HANDLERS.search_docs; - - vi.spyOn(global, "fetch").mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - json: async () => ({ error: "Internal server error" }), - } as Response); - - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - query: "test query", - maxResults: undefined, - guide: undefined, - }, - ), - ).rejects.toThrow(); - }); - - it("handles timeout errors", async () => { - const tool = TOOL_HANDLERS.search_docs; - - // Mock fetch to simulate a timeout by throwing an AbortError - vi.spyOn(global, "fetch").mockImplementationOnce(() => { - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - return Promise.reject(error); - }); - - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - query: "test query", - maxResults: undefined, - guide: undefined, - }, - ), - ).rejects.toThrow("Request timeout after 15000ms"); - }); - - it("includes platform in output and request", async () => { - const tool = TOOL_HANDLERS.search_docs; - const mockFetch = vi.spyOn(global, "fetch"); - - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - host: "https://mcp.sentry.dev", - }, - { - query: "test query", - maxResults: 5, - guide: "javascript/nextjs", - }, - ); - - // Check that platform is included in the output - expect(result).toContain("**Guide**: javascript/nextjs"); - - // Check that platform is included in the request - expect(mockFetch).toHaveBeenCalledWith( - "https://mcp.sentry.dev/api/search", - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: "test query", - maxResults: 5, - guide: "javascript/nextjs", - }), - }), - ); - }); -}); - -describe("get_doc", () => { - it("returns document content", async () => { - const tool = TOOL_HANDLERS.get_doc; - const result = await tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - host: "https://mcp.sentry.dev", - }, - { - path: "/product/rate-limiting.md", - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Documentation Content - - **Path**: /product/rate-limiting.md - - --- - - # Project Rate Limits and Quotas - - Rate limiting allows you to control the volume of events that Sentry accepts from your applications. This helps you manage costs and ensures that a sudden spike in errors doesn't consume your entire quota. - - ## Why Use Rate Limiting? - - - **Cost Control**: Prevent unexpected charges from error spikes - - **Noise Reduction**: Filter out repetitive or low-value events - - **Resource Management**: Ensure critical projects have quota available - - **Performance**: Reduce load on your Sentry organization - - ## Types of Rate Limits - - ### 1. Organization Rate Limits - - Set a maximum number of events per hour across your entire organization: - - \`\`\`python - # In your organization settings - rate_limit = 1000 # events per hour - \`\`\` - - ### 2. Project Rate Limits - - Configure limits for specific projects: - - \`\`\`javascript - // Project settings - { - "rateLimit": { - "window": 3600, // 1 hour in seconds - "limit": 500 // max events - } - } - \`\`\` - - ### 3. Key-Based Rate Limiting - - Rate limit by specific attributes: - - - **By Release**: Limit events from specific releases - - **By User**: Prevent single users from consuming quota - - **By Transaction**: Control high-volume transactions - - ## Configuration Examples - - ### SDK Configuration - - Configure client-side sampling to reduce events before they're sent: - - \`\`\`javascript - Sentry.init({ - dsn: "your-dsn", - tracesSampleRate: 0.1, // Sample 10% of transactions - beforeSend(event) { - // Custom filtering logic - if (event.exception?.values?.[0]?.value?.includes("NetworkError")) { - return null; // Drop network errors - } - return event; - } - }); - \`\`\` - - ### Inbound Filters - - Use Sentry's inbound filters to drop events server-side: - - 1. Go to **Project Settings** → **Inbound Filters** - 2. Enable filters for: - - Legacy browsers - - Web crawlers - - Specific error messages - - IP addresses - - ### Spike Protection - - Enable spike protection to automatically limit events during traffic spikes: - - \`\`\`python - # Project settings - spike_protection = { - "enabled": True, - "max_events_per_hour": 10000, - "detection_window": 300 # 5 minutes - } - \`\`\` - - ## Best Practices - - 1. **Start Conservative**: Begin with lower limits and increase as needed - 2. **Monitor Usage**: Regularly review your quota consumption - 3. **Use Sampling**: Implement transaction sampling for high-volume apps - 4. **Filter Noise**: Drop known low-value events at the SDK level - 5. **Set Alerts**: Configure notifications for quota thresholds - - ## Rate Limit Headers - - Sentry returns rate limit information in response headers: - - \`\`\` - X-Sentry-Rate-Limit: 60 - X-Sentry-Rate-Limit-Remaining: 42 - X-Sentry-Rate-Limit-Reset: 1634567890 - \`\`\` - - ## Quota Management - - ### Viewing Quota Usage - - 1. Navigate to **Settings** → **Subscription** - 2. View usage by: - - Project - - Event type - - Time period - - ### On-Demand Budgets - - Purchase additional events when approaching limits: - - \`\`\`bash - # Via API - curl -X POST https://sentry.io/api/0/organizations/{org}/quotas/ \\ - -H 'Authorization: Bearer ' \\ - -d '{"events": 100000}' - \`\`\` - - ## Troubleshooting - - ### Events Being Dropped? - - Check: - 1. Organization and project rate limits - 2. Spike protection status - 3. SDK sampling configuration - 4. Inbound filter settings - - ### Rate Limit Errors - - If you see 429 errors: - - Review your rate limit configuration - - Implement exponential backoff - - Consider event buffering - - ## Related Documentation - - - [SDK Configuration Guide](/platforms/javascript/configuration) - - [Quotas and Billing](/product/quotas) - - [Filtering Events](/product/data-management/filtering) - - --- - - ## Using this documentation - - - This is the raw markdown content from Sentry's documentation - - Code examples and configuration snippets can be copied directly - - Links in the documentation are relative to https://docs.sentry.io - - For more related topics, use \`search_docs()\` to find additional pages - " - `); - }); - - it("handles invalid path format", async () => { - const tool = TOOL_HANDLERS.get_doc; - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - path: "/product/rate-limiting", // Missing .md extension - }, - ), - ).rejects.toThrow( - "Invalid documentation path. Path must end with .md extension.", - ); - }); - - it("handles API errors", async () => { - const tool = TOOL_HANDLERS.get_doc; - - vi.spyOn(global, "fetch").mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - } as Response); - - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - path: "/product/test.md", - }, - ), - ).rejects.toThrow(); - }); - - it("validates domain whitelist", async () => { - const tool = TOOL_HANDLERS.get_doc; - - // Test with absolute URL that would resolve to a different domain - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - path: "https://malicious.com/test.md", - }, - ), - ).rejects.toThrow( - "Invalid domain. Documentation can only be fetched from allowed domains: docs.sentry.io, develop.sentry.io", - ); - }); - - it("handles timeout errors", async () => { - const tool = TOOL_HANDLERS.get_doc; - - // Mock fetch to simulate a timeout by throwing an AbortError - vi.spyOn(global, "fetch").mockImplementationOnce(() => { - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - return Promise.reject(error); - }); - - await expect( - tool( - { - accessToken: "access-token", - userId: "1", - organizationSlug: null, - }, - { - path: "/product/test.md", - }, - ), - ).rejects.toThrow("Request timeout after 15000ms"); - }); -}); diff --git a/packages/mcp-server/src/tools/analyze-issue-with-seer.test.ts b/packages/mcp-server/src/tools/analyze-issue-with-seer.test.ts new file mode 100644 index 000000000..e2439dc54 --- /dev/null +++ b/packages/mcp-server/src/tools/analyze-issue-with-seer.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import analyzeIssueWithSeer from "./analyze-issue-with-seer.js"; + +describe("analyze_issue_with_seer", () => { + it("handles combined workflow", async () => { + // This test validates the tool works correctly + // In a real scenario, it would poll multiple times, but for testing + // we'll validate the key outputs are present + const result = await analyzeIssueWithSeer.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-45", + issueUrl: undefined, + regionUrl: undefined, + instruction: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + + expect(result).toContain("# Seer AI Analysis for Issue CLOUDFLARE-MCP-45"); + expect(result).toContain("Found existing analysis (Run ID: 13)"); + expect(result).toContain("## Analysis Complete"); + expect(result).toContain("## 1. **Root Cause Analysis**"); + expect(result).toContain("The analysis has completed successfully."); + }); +}); diff --git a/packages/mcp-server/src/tools/create-dsn.test.ts b/packages/mcp-server/src/tools/create-dsn.test.ts new file mode 100644 index 000000000..53649c3cb --- /dev/null +++ b/packages/mcp-server/src/tools/create-dsn.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import createDsn from "./create-dsn.js"; + +describe("create_dsn", () => { + it("serializes", async () => { + const result = await createDsn.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + name: "Default", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# New DSN in **sentry-mcp-evals/cloudflare-mcp** + + **DSN**: https://d20df0a1ab5031c7f3c7edca9c02814d@o4509106732793856.ingest.us.sentry.io/4509109104082945 + **Name**: Default + + # Using this information + + - The \`SENTRY_DSN\` value is a URL that you can use to initialize Sentry's SDKs. + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/create-project.test.ts b/packages/mcp-server/src/tools/create-project.test.ts new file mode 100644 index 000000000..6bca72aec --- /dev/null +++ b/packages/mcp-server/src/tools/create-project.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import createProject from "./create-project.js"; + +describe("create_project", () => { + it("serializes", async () => { + const result = await createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + platform: "node", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# New Project in **sentry-mcp-evals** + + **ID**: 4509109104082945 + **Slug**: cloudflare-mcp + **Name**: cloudflare-mcp + **SENTRY_DSN**: https://d20df0a1ab5031c7f3c7edca9c02814d@o4509106732793856.ingest.us.sentry.io/4509109104082945 + + # Using this information + + - You can reference the **SENTRY_DSN** value to initialize Sentry's SDKs. + - You should always inform the user of the **SENTRY_DSN** and Project Slug values. + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/create-team.test.ts b/packages/mcp-server/src/tools/create-team.test.ts new file mode 100644 index 000000000..abd40f59c --- /dev/null +++ b/packages/mcp-server/src/tools/create-team.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import createTeam from "./create-team.js"; + +describe("create_team", () => { + it("serializes", async () => { + const result = await createTeam.handler( + { + organizationSlug: "sentry-mcp-evals", + name: "the-goats", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# New Team in **sentry-mcp-evals** + + **ID**: 4509109078196224 + **Slug**: the-goats + **Name**: the-goats + # Using this information + + - You should always inform the user of the Team Slug value. + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/find-dsns.test.ts b/packages/mcp-server/src/tools/find-dsns.test.ts new file mode 100644 index 000000000..4f6c23ef6 --- /dev/null +++ b/packages/mcp-server/src/tools/find-dsns.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import findDsns from "./find-dsns.js"; + +describe("find_dsns", () => { + it("serializes", async () => { + const result = await findDsns.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# DSNs in **sentry-mcp-evals/cloudflare-mcp** + + ## Default + **ID**: d20df0a1ab5031c7f3c7edca9c02814d + **DSN**: https://d20df0a1ab5031c7f3c7edca9c02814d@o4509106732793856.ingest.us.sentry.io/4509109104082945 + + # Using this information + + - The \`SENTRY_DSN\` value is a URL that you can use to initialize Sentry's SDKs. + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/find-errors.test.ts b/packages/mcp-server/src/tools/find-errors.test.ts new file mode 100644 index 000000000..43de4fb68 --- /dev/null +++ b/packages/mcp-server/src/tools/find-errors.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import findErrors from "./find-errors.js"; + +describe("find_errors", () => { + it("serializes", async () => { + const result = await findErrors.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: undefined, + filename: undefined, + transaction: undefined, + query: undefined, + sortBy: "count", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Errors in **sentry-mcp-evals** + + + ## CLOUDFLARE-MCP-41 + + **Description**: Error: Tool list_organizations is already registered + **Issue ID**: CLOUDFLARE-MCP-41 + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + **Project**: test-suite + **Last Seen**: 2025-04-07T12:23:39+00:00 + **Occurrences**: 2 + + # Using this information + + - You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged. + - You can get more details about an error by using the tool: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId=)\` + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/find-issues.test.ts b/packages/mcp-server/src/tools/find-issues.test.ts new file mode 100644 index 000000000..f073e8fd0 --- /dev/null +++ b/packages/mcp-server/src/tools/find-issues.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import findIssues from "./find-issues.js"; + +describe("find_issues", () => { + it("serializes with project", async () => { + const result = await findIssues.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + query: undefined, + sortBy: "last_seen", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Issues in **sentry-mcp-evals/cloudflare-mcp** + + ## CLOUDFLARE-MCP-41 + + **Description**: Error: Tool list_organizations is already registered + **Culprit**: Object.fetch(index) + **First Seen**: 2025-04-03T22:51:19.403Z + **Last Seen**: 2025-04-12T11:34:11.000Z + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + + ## CLOUDFLARE-MCP-42 + + **Description**: Error: Tool list_issues is already registered + **Culprit**: Object.fetch(index) + **First Seen**: 2025-04-11T22:51:19.403Z + **Last Seen**: 2025-04-12T11:34:11.000Z + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-42 + + # Using this information + + - You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged. + - You can get more details about a specific issue by using the tool: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId=)\` + " + `); + }); + + it("serializes without project", async () => { + const result = await findIssues.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: undefined, + query: undefined, + sortBy: "last_seen", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Issues in **sentry-mcp-evals** + + ## CLOUDFLARE-MCP-41 + + **Description**: Error: Tool list_organizations is already registered + **Culprit**: Object.fetch(index) + **First Seen**: 2025-04-03T22:51:19.403Z + **Last Seen**: 2025-04-12T11:34:11.000Z + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + + ## CLOUDFLARE-MCP-42 + + **Description**: Error: Tool list_issues is already registered + **Culprit**: Object.fetch(index) + **First Seen**: 2025-04-11T22:51:19.403Z + **Last Seen**: 2025-04-12T11:34:11.000Z + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-42 + + # Using this information + + - You can reference the Issue ID in commit messages (e.g. \`Fixes \`) to automatically close the issue when the commit is merged. + - You can get more details about a specific issue by using the tool: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId=)\` + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/find-organizations.test.ts b/packages/mcp-server/src/tools/find-organizations.test.ts new file mode 100644 index 000000000..e0d2b6339 --- /dev/null +++ b/packages/mcp-server/src/tools/find-organizations.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import findOrganizations from "./find-organizations.js"; + +describe("find_organizations", () => { + it("serializes", async () => { + const result = await findOrganizations.handler( + {}, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Organizations + + ## **sentry-mcp-evals** + + **Web URL:** https://sentry.io/sentry-mcp-evals + **Region URL:** https://us.sentry.io + + # Using this information + + - The organization's name is the identifier for the organization, and is used in many tools for \`organizationSlug\`. + - If a tool supports passing in the \`regionUrl\`, you MUST pass in the correct value shown above for each organization. + - For Sentry's Cloud Service (sentry.io), always use the regionUrl to ensure requests go to the correct region. + " + `); + }); + + it("handles empty regionUrl parameter", async () => { + const result = await findOrganizations.handler( + {}, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toContain("Organizations"); + }); + + it("handles undefined regionUrl parameter", async () => { + const result = await findOrganizations.handler( + {}, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toContain("Organizations"); + }); +}); diff --git a/packages/mcp-server/src/tools/find-projects.test.ts b/packages/mcp-server/src/tools/find-projects.test.ts new file mode 100644 index 000000000..858c52cc2 --- /dev/null +++ b/packages/mcp-server/src/tools/find-projects.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import findProjects from "./find-projects.js"; + +describe("find_projects", () => { + it("serializes", async () => { + const result = await findProjects.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Projects in **sentry-mcp-evals** + + - **cloudflare-mcp** + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/find-releases.test.ts b/packages/mcp-server/src/tools/find-releases.test.ts new file mode 100644 index 000000000..5fd58832a --- /dev/null +++ b/packages/mcp-server/src/tools/find-releases.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import findReleases from "./find-releases.js"; + +describe("find_releases", () => { + it("works without project", async () => { + const result = await findReleases.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: undefined, + regionUrl: undefined, + query: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Releases in **sentry-mcp-evals** + + ## 8ce89484-0fec-4913-a2cd-e8e2d41dee36 + + **Created**: 2025-04-13T19:54:21.764Z + **First Event**: 2025-04-13T19:54:21.000Z + **Last Event**: 2025-04-13T20:28:23.000Z + **New Issues**: 0 + **Projects**: cloudflare-mcp + + # Using this information + + - You can reference the Release version in commit messages or documentation. + - You can search for issues in a specific release using the \`find_errors()\` tool with the query \`release:8ce89484-0fec-4913-a2cd-e8e2d41dee36\`. + " + `); + }); + + it("works with project", async () => { + const result = await findReleases.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + regionUrl: undefined, + query: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Releases in **sentry-mcp-evals/cloudflare-mcp** + + ## 8ce89484-0fec-4913-a2cd-e8e2d41dee36 + + **Created**: 2025-04-13T19:54:21.764Z + **First Event**: 2025-04-13T19:54:21.000Z + **Last Event**: 2025-04-13T20:28:23.000Z + **New Issues**: 0 + **Projects**: cloudflare-mcp + + # Using this information + + - You can reference the Release version in commit messages or documentation. + - You can search for issues in a specific release using the \`find_errors()\` tool with the query \`release:8ce89484-0fec-4913-a2cd-e8e2d41dee36\`. + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/find-tags.test.ts b/packages/mcp-server/src/tools/find-tags.test.ts new file mode 100644 index 000000000..98dc47b2a --- /dev/null +++ b/packages/mcp-server/src/tools/find-tags.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import findTags from "./find-tags.js"; + +describe("find_tags", () => { + it("works", async () => { + const result = await findTags.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Tags in **sentry-mcp-evals** + + - transaction + - runtime.name + - level + - device + - os + - user + - runtime + - release + - url + - uptime_rule + - server_name + - browser + - os.name + - device.family + - replayId + - client_os.name + - environment + - service + - browser.name + + # Using this information + + - You can reference tags in the \`query\` parameter of various tools: \`tagName:tagValue\`. + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/find-teams.test.ts b/packages/mcp-server/src/tools/find-teams.test.ts new file mode 100644 index 000000000..cdff99aa8 --- /dev/null +++ b/packages/mcp-server/src/tools/find-teams.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import findTeams from "./find-teams.js"; + +describe("find_teams", () => { + it("serializes", async () => { + const result = await findTeams.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Teams in **sentry-mcp-evals** + + - the-goats + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/find-transactions.test.ts b/packages/mcp-server/src/tools/find-transactions.test.ts new file mode 100644 index 000000000..74330bedf --- /dev/null +++ b/packages/mcp-server/src/tools/find-transactions.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import findTransactions from "./find-transactions.js"; + +describe("find_transactions", () => { + it("serializes", async () => { + const result = await findTransactions.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: undefined, + transaction: undefined, + query: undefined, + sortBy: "duration", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Transactions in **sentry-mcp-evals** + + + ## \`GET /trpc/bottleList\` + + **Span ID**: 07752c6aeb027c8f + **Trace ID**: 6a477f5b0f31ef7b6b9b5e1dea66c91d + **Span Operation**: http.server + **Span Description**: GET /trpc/bottleList + **Duration**: 12 + **Timestamp**: 2025-04-13T14:19:18+00:00 + **Project**: peated + **URL**: https://sentry-mcp-evals.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d + + ## \`GET /trpc/bottleList\` + + **Span ID**: 7ab5edf5b3ba42c9 + **Trace ID**: 54177131c7b192a446124daba3136045 + **Span Operation**: http.server + **Span Description**: GET /trpc/bottleList + **Duration**: 18 + **Timestamp**: 2025-04-13T14:19:17+00:00 + **Project**: peated + **URL**: https://sentry-mcp-evals.sentry.io/explore/traces/trace/54177131c7b192a446124daba3136045 + + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/get-doc.test.ts b/packages/mcp-server/src/tools/get-doc.test.ts new file mode 100644 index 000000000..2191ea8f2 --- /dev/null +++ b/packages/mcp-server/src/tools/get-doc.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi } from "vitest"; +import getDoc from "./get-doc.js"; + +describe("get_doc", () => { + it("returns document content", async () => { + const result = await getDoc.handler( + { + path: "/product/rate-limiting.md", + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + host: "https://mcp.sentry.dev", + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Documentation Content + + **Path**: /product/rate-limiting.md + + --- + + # Project Rate Limits and Quotas + + Rate limiting allows you to control the volume of events that Sentry accepts from your applications. This helps you manage costs and ensures that a sudden spike in errors doesn't consume your entire quota. + + ## Why Use Rate Limiting? + + - **Cost Control**: Prevent unexpected charges from error spikes + - **Noise Reduction**: Filter out repetitive or low-value events + - **Resource Management**: Ensure critical projects have quota available + - **Performance**: Reduce load on your Sentry organization + + ## Types of Rate Limits + + ### 1. Organization Rate Limits + + Set a maximum number of events per hour across your entire organization: + + \`\`\`python + # In your organization settings + rate_limit = 1000 # events per hour + \`\`\` + + ### 2. Project Rate Limits + + Configure limits for specific projects: + + \`\`\`javascript + // Project settings + { + "rateLimit": { + "window": 3600, // 1 hour in seconds + "limit": 500 // max events + } + } + \`\`\` + + ### 3. Key-Based Rate Limiting + + Rate limit by specific attributes: + + - **By Release**: Limit events from specific releases + - **By User**: Prevent single users from consuming quota + - **By Transaction**: Control high-volume transactions + + ## Configuration Examples + + ### SDK Configuration + + Configure client-side sampling to reduce events before they're sent: + + \`\`\`javascript + Sentry.init({ + dsn: "your-dsn", + tracesSampleRate: 0.1, // Sample 10% of transactions + beforeSend(event) { + // Custom filtering logic + if (event.exception?.values?.[0]?.value?.includes("NetworkError")) { + return null; // Drop network errors + } + return event; + } + }); + \`\`\` + + ### Inbound Filters + + Use Sentry's inbound filters to drop events server-side: + + 1. Go to **Project Settings** → **Inbound Filters** + 2. Enable filters for: + - Legacy browsers + - Web crawlers + - Specific error messages + - IP addresses + + ### Spike Protection + + Enable spike protection to automatically limit events during traffic spikes: + + \`\`\`python + # Project settings + spike_protection = { + "enabled": True, + "max_events_per_hour": 10000, + "detection_window": 300 # 5 minutes + } + \`\`\` + + ## Best Practices + + 1. **Start Conservative**: Begin with lower limits and increase as needed + 2. **Monitor Usage**: Regularly review your quota consumption + 3. **Use Sampling**: Implement transaction sampling for high-volume apps + 4. **Filter Noise**: Drop known low-value events at the SDK level + 5. **Set Alerts**: Configure notifications for quota thresholds + + ## Rate Limit Headers + + Sentry returns rate limit information in response headers: + + \`\`\` + X-Sentry-Rate-Limit: 60 + X-Sentry-Rate-Limit-Remaining: 42 + X-Sentry-Rate-Limit-Reset: 1634567890 + \`\`\` + + ## Quota Management + + ### Viewing Quota Usage + + 1. Navigate to **Settings** → **Subscription** + 2. View usage by: + - Project + - Event type + - Time period + + ### On-Demand Budgets + + Purchase additional events when approaching limits: + + \`\`\`bash + # Via API + curl -X POST https://sentry.io/api/0/organizations/{org}/quotas/ \\ + -H 'Authorization: Bearer ' \\ + -d '{"events": 100000}' + \`\`\` + + ## Troubleshooting + + ### Events Being Dropped? + + Check: + 1. Organization and project rate limits + 2. Spike protection status + 3. SDK sampling configuration + 4. Inbound filter settings + + ### Rate Limit Errors + + If you see 429 errors: + - Review your rate limit configuration + - Implement exponential backoff + - Consider event buffering + + ## Related Documentation + + - [SDK Configuration Guide](/platforms/javascript/configuration) + - [Quotas and Billing](/product/quotas) + - [Filtering Events](/product/data-management/filtering) + + --- + + ## Using this documentation + + - This is the raw markdown content from Sentry's documentation + - Code examples and configuration snippets can be copied directly + - Links in the documentation are relative to https://docs.sentry.io + - For more related topics, use \`search_docs()\` to find additional pages + " + `); + }); + + it("handles invalid path format", async () => { + await expect( + getDoc.handler( + { + path: "/product/rate-limiting", // Missing .md extension + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow( + "Invalid documentation path. Path must end with .md extension.", + ); + }); + + it("handles API errors", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + } as Response); + + await expect( + getDoc.handler( + { + path: "/product/test.md", + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow(); + }); + + it("validates domain whitelist", async () => { + // Test with absolute URL that would resolve to a different domain + await expect( + getDoc.handler( + { + path: "https://malicious.com/test.md", + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow( + "Invalid domain. Documentation can only be fetched from allowed domains: docs.sentry.io, develop.sentry.io", + ); + }); + + it("handles timeout errors", async () => { + // Mock fetch to simulate a timeout by throwing an AbortError + vi.spyOn(global, "fetch").mockImplementationOnce(() => { + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + return Promise.reject(error); + }); + + await expect( + getDoc.handler( + { + path: "/product/test.md", + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow("Request timeout after 15000ms"); + }); +}); diff --git a/packages/mcp-server/src/tools/get-issue-details.test.ts b/packages/mcp-server/src/tools/get-issue-details.test.ts new file mode 100644 index 000000000..b4ba3fb10 --- /dev/null +++ b/packages/mcp-server/src/tools/get-issue-details.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect } from "vitest"; +import getIssueDetails from "./get-issue-details.js"; + +describe("get_issue_details", () => { + it("serializes with issueId", async () => { + const result = await getIssueDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + eventId: undefined, + issueUrl: undefined, + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals** + + **Description**: Error: Tool list_organizations is already registered + **Culprit**: Object.fetch(index) + **First Seen**: 2025-04-03T22:51:19.403Z + **Last Seen**: 2025-04-12T11:34:11.000Z + **Occurrences**: 25 + **Users Impacted**: 1 + **Status**: unresolved + **Platform**: javascript + **Project**: CLOUDFLARE-MCP + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + + ## Event Details + + **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51 + **Occurred At**: 2025-04-08T21:15:04.000Z + + ### Error + + \`\`\` + Error: Tool list_organizations is already registered + \`\`\` + + **Stacktrace:** + \`\`\` + index.js:7809:27 + index.js:8029:24 (OAuthProviderImpl.fetch) + index.js:19631:28 (Object.fetch) + \`\`\` + + ### HTTP Request + + **Method:** GET + **URL:** https://mcp.sentry.dev/sse + + ### Additional Context + + These are additional context provided by the user when they're instrumenting their application. + + **cloud_resource** + cloud.provider: "cloudflare" + + **culture** + timezone: "Europe/London" + + **runtime** + name: "cloudflare" + + **trace** + trace_id: "3032af8bcdfe4423b937fc5c041d5d82" + span_id: "953da703d2a6f4c7" + status: "unknown" + client_sample_rate: 1 + sampled: true + + # Using this information + + - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged. + - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code. + " + `); + }); + + it("serializes with issueUrl", async () => { + const result = await getIssueDetails.handler( + { + organizationSlug: undefined, + issueId: undefined, + eventId: undefined, + issueUrl: "https://sentry-mcp-evals.sentry.io/issues/6507376925", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + + expect(result).toMatchInlineSnapshot(` + "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals** + + **Description**: Error: Tool list_organizations is already registered + **Culprit**: Object.fetch(index) + **First Seen**: 2025-04-03T22:51:19.403Z + **Last Seen**: 2025-04-12T11:34:11.000Z + **Occurrences**: 25 + **Users Impacted**: 1 + **Status**: unresolved + **Platform**: javascript + **Project**: CLOUDFLARE-MCP + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + + ## Event Details + + **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51 + **Occurred At**: 2025-04-08T21:15:04.000Z + + ### Error + + \`\`\` + Error: Tool list_organizations is already registered + \`\`\` + + **Stacktrace:** + \`\`\` + index.js:7809:27 + index.js:8029:24 (OAuthProviderImpl.fetch) + index.js:19631:28 (Object.fetch) + \`\`\` + + ### HTTP Request + + **Method:** GET + **URL:** https://mcp.sentry.dev/sse + + ### Additional Context + + These are additional context provided by the user when they're instrumenting their application. + + **cloud_resource** + cloud.provider: "cloudflare" + + **culture** + timezone: "Europe/London" + + **runtime** + name: "cloudflare" + + **trace** + trace_id: "3032af8bcdfe4423b937fc5c041d5d82" + span_id: "953da703d2a6f4c7" + status: "unknown" + client_sample_rate: 1 + sampled: true + + # Using this information + + - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged. + - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code. + " + `); + }); + + it("serializes with eventId", async () => { + const result = await getIssueDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: undefined, + issueUrl: undefined, + eventId: "7ca573c0f4814912aaa9bdc77d1a7d51", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals** + + **Description**: Error: Tool list_organizations is already registered + **Culprit**: Object.fetch(index) + **First Seen**: 2025-04-03T22:51:19.403Z + **Last Seen**: 2025-04-12T11:34:11.000Z + **Occurrences**: 25 + **Users Impacted**: 1 + **Status**: unresolved + **Platform**: javascript + **Project**: CLOUDFLARE-MCP + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + + ## Event Details + + **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51 + **Occurred At**: 2025-04-08T21:15:04.000Z + + ### Error + + \`\`\` + Error: Tool list_organizations is already registered + \`\`\` + + **Stacktrace:** + \`\`\` + index.js:7809:27 + index.js:8029:24 (OAuthProviderImpl.fetch) + index.js:19631:28 (Object.fetch) + \`\`\` + + ### HTTP Request + + **Method:** GET + **URL:** https://mcp.sentry.dev/sse + + ### Additional Context + + These are additional context provided by the user when they're instrumenting their application. + + **cloud_resource** + cloud.provider: "cloudflare" + + **culture** + timezone: "Europe/London" + + **runtime** + name: "cloudflare" + + **trace** + trace_id: "3032af8bcdfe4423b937fc5c041d5d82" + span_id: "953da703d2a6f4c7" + status: "unknown" + client_sample_rate: 1 + sampled: true + + # Using this information + + - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged. + - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code. + " + `); + }); + + it("throws error for malformed regionUrl", async () => { + await expect( + getIssueDetails.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + eventId: undefined, + issueUrl: undefined, + regionUrl: "https", + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow( + "Invalid regionUrl provided: https. Must be a valid URL.", + ); + }); +}); diff --git a/packages/mcp-server/src/tools/search-docs.test.ts b/packages/mcp-server/src/tools/search-docs.test.ts new file mode 100644 index 000000000..a23aadfd4 --- /dev/null +++ b/packages/mcp-server/src/tools/search-docs.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from "vitest"; +import searchDocs from "./search-docs.js"; + +describe("search_docs", () => { + // Note: Query validation (empty, too short, too long) is now handled by Zod schema + // These validation tests are no longer needed as they test framework behavior, not our tool logic + + it("returns results from the API", async () => { + const result = await searchDocs.handler( + { + query: "How do I configure rate limiting?", + maxResults: 5, + guide: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + host: "https://mcp.sentry.dev", + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Documentation Search Results + + **Query**: "How do I configure rate limiting?" + + Found 2 matches + + These are just snippets. Use \`get_doc(path='...')\` to fetch the full content. + + ## 1. https://docs.sentry.io/product/rate-limiting + + **Path**: product/rate-limiting.md + **Relevance**: 95.0% + + **Matching Context** + > Learn how to configure rate limiting in Sentry to prevent quota exhaustion and control event ingestion. + + ## 2. https://docs.sentry.io/product/accounts/quotas/spike-protection + + **Path**: product/accounts/quotas/spike-protection.md + **Relevance**: 87.0% + + **Matching Context** + > Spike protection helps prevent unexpected spikes in event volume from consuming your quota. + + " + `); + }); + + it("handles API errors", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => ({ error: "Internal server error" }), + } as Response); + + await expect( + searchDocs.handler( + { + query: "test query", + maxResults: undefined, + guide: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow(); + }); + + it("handles timeout errors", async () => { + // Mock fetch to simulate a timeout by throwing an AbortError + vi.spyOn(global, "fetch").mockImplementationOnce(() => { + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + return Promise.reject(error); + }); + + await expect( + searchDocs.handler( + { + query: "test query", + maxResults: undefined, + guide: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow("Request timeout after 15000ms"); + }); + + it("includes platform in output and request", async () => { + const mockFetch = vi.spyOn(global, "fetch"); + + const result = await searchDocs.handler( + { + query: "test query", + maxResults: 5, + guide: "javascript/nextjs", + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + host: "https://mcp.sentry.dev", + }, + ); + + // Check that platform is included in the output + expect(result).toContain("**Guide**: javascript/nextjs"); + + // Check that platform is included in the request + expect(mockFetch).toHaveBeenCalledWith( + "https://mcp.sentry.dev/api/search", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: "test query", + maxResults: 5, + guide: "javascript/nextjs", + }), + }), + ); + }); +}); diff --git a/packages/mcp-server/src/tools/update-issue.test.ts b/packages/mcp-server/src/tools/update-issue.test.ts new file mode 100644 index 000000000..9b13c0a6a --- /dev/null +++ b/packages/mcp-server/src/tools/update-issue.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from "vitest"; +import updateIssue from "./update-issue.js"; + +describe("update_issue", () => { + it("updates issue status", async () => { + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Issue CLOUDFLARE-MCP-41 Updated in **sentry-mcp-evals** + + **Issue**: Error: Tool list_organizations is already registered + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + + ## Changes Made + + **Status**: unresolved → **resolved** + + ## Current Status + + **Status**: resolved + **Assigned To**: Unassigned + + # Using this information + + - The issue has been successfully updated in Sentry + - You can view the issue details using: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId="CLOUDFLARE-MCP-41")\` + - The issue is now marked as resolved and will no longer generate alerts + " + `); + }); + + it("updates issue assignment", async () => { + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: undefined, + assignedTo: "john.doe", + issueUrl: undefined, + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Issue CLOUDFLARE-MCP-41 Updated in **sentry-mcp-evals** + + **Issue**: Error: Tool list_organizations is already registered + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + + ## Changes Made + + **Assigned To**: Unassigned → **john.doe** + + ## Current Status + + **Status**: unresolved + **Assigned To**: john.doe + + # Using this information + + - The issue has been successfully updated in Sentry + - You can view the issue details using: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId="CLOUDFLARE-MCP-41")\` + " + `); + }); + + it("updates both status and assignment", async () => { + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + assignedTo: "me", + issueUrl: undefined, + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Issue CLOUDFLARE-MCP-41 Updated in **sentry-mcp-evals** + + **Issue**: Error: Tool list_organizations is already registered + **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41 + + ## Changes Made + + **Status**: unresolved → **resolved** + **Assigned To**: Unassigned → **You** + + ## Current Status + + **Status**: resolved + **Assigned To**: me + + # Using this information + + - The issue has been successfully updated in Sentry + - You can view the issue details using: \`get_issue_details(organizationSlug="sentry-mcp-evals", issueId="CLOUDFLARE-MCP-41")\` + - The issue is now marked as resolved and will no longer generate alerts + " + `); + }); + + it("validates required parameters", async () => { + await expect( + updateIssue.handler( + { + organizationSlug: undefined, + issueId: undefined, + status: undefined, + assignedTo: undefined, + issueUrl: undefined, + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow("Either `issueId` or `issueUrl` must be provided"); + }); + + it("validates organization slug when using issueId", async () => { + await expect( + updateIssue.handler( + { + organizationSlug: undefined, + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow( + "`organizationSlug` is required when providing `issueId`", + ); + }); + + it("validates update parameters", async () => { + await expect( + updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: undefined, + assignedTo: undefined, + issueUrl: undefined, + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ), + ).rejects.toThrow( + "At least one of `status` or `assignedTo` must be provided to update the issue", + ); + }); +}); diff --git a/packages/mcp-server/src/tools/update-project.test.ts b/packages/mcp-server/src/tools/update-project.test.ts new file mode 100644 index 000000000..6e3eba46a --- /dev/null +++ b/packages/mcp-server/src/tools/update-project.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import updateProject from "./update-project.js"; + +describe("update_project", () => { + it("updates name and platform", async () => { + const result = await updateProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + name: "New Project Name", + slug: undefined, + platform: "python", + teamSlug: undefined, + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Updated Project in **sentry-mcp-evals** + + **ID**: 4509109104082945 + **Slug**: cloudflare-mcp + **Name**: New Project Name + **Platform**: python + + ## Updates Applied + - Updated name to "New Project Name" + - Updated platform to "python" + + # Using this information + + - The project is now accessible at slug: \`cloudflare-mcp\` + " + `); + }); + + it("assigns project to new team", async () => { + const result = await updateProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + name: undefined, + slug: undefined, + platform: undefined, + teamSlug: "backend-team", + regionUrl: undefined, + }, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot(` + "# Updated Project in **sentry-mcp-evals** + + **ID**: 4509106749636608 + **Slug**: cloudflare-mcp + **Name**: cloudflare-mcp + **Platform**: node + + ## Updates Applied + - Updated team assignment to "backend-team" + + # Using this information + + - The project is now accessible at slug: \`cloudflare-mcp\` + - The project is now assigned to the \`backend-team\` team + " + `); + }); +}); diff --git a/packages/mcp-server/src/tools/whoami.test.ts b/packages/mcp-server/src/tools/whoami.test.ts new file mode 100644 index 000000000..e94efefe4 --- /dev/null +++ b/packages/mcp-server/src/tools/whoami.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import whoami from "./whoami.js"; + +describe("whoami", () => { + it("serializes", async () => { + const result = await whoami.handler( + {}, + { + accessToken: "access-token", + userId: "1", + organizationSlug: null, + }, + ); + expect(result).toMatchInlineSnapshot( + ` + "You are authenticated as John Doe (john.doe@example.com). + + Your Sentry User ID is 1." + `, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 881303d53..32fc11cc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ catalogs: zod: specifier: ^3.25.67 version: 3.25.72 + zod-to-json-schema: + specifier: ^3.24.6 + version: 3.24.6 importers: @@ -361,6 +364,9 @@ importers: msw: specifier: 'catalog:' version: 2.10.2(@types/node@24.0.10)(typescript@5.8.3) + zod-to-json-schema: + specifier: 'catalog:' + version: 3.24.6(zod@3.25.72) packages/mcp-server-evals: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cf24c5937..0e3c1d2c5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,3 +55,4 @@ catalog: workers-mcp: 0.1.0-3 wrangler: ^4.22.0 zod: ^3.25.67 + zod-to-json-schema: ^3.24.6 From 83198716ba8084eb6628526d7939eaa952e11abe Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 8 Jul 2025 11:17:57 -0700 Subject: [PATCH 4/7] revert invalid change --- CLAUDE.md | 8 +- docs/cursor.mdc | 10 ++- packages/mcp-server/package.json | 2 +- packages/mcp-server/src/server.ts | 136 +++++++++--------------------- 4 files changed, 55 insertions(+), 101 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ee85c7663..2ec10b4d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,9 +79,13 @@ When making changes, consider these areas: - CLAUDE.md ↔ cursor.mdc must stay synchronized - Update relevant docs for tools, prompts, resources, API patterns, or architecture changes -## Quality Checks +## Code Validation Requirements -**MUST run after ANY code changes:** See `docs/quality-checks.mdc` +**MANDATORY after ANY code changes:** +- Run `pnpm run tsc` to verify type safety +- Run `pnpm run lint` to check code style +- Run `pnpm run test` for affected components +- See `docs/quality-checks.mdc` for complete checklist ## Component Limits diff --git a/docs/cursor.mdc b/docs/cursor.mdc index 18b158b15..7fea279f1 100644 --- a/docs/cursor.mdc +++ b/docs/cursor.mdc @@ -87,9 +87,15 @@ You should ALWAYS update docs when they are inaccurate or you have learned new r - Never exceed 25 tools (absolute maximum) - This limit exists in Cursor and possibly other tools -## Critical Quality Checks +## Code Validation Requirements -**After ANY code changes, you MUST run:** +**MANDATORY after ANY code changes:** +- Run `pnpm run tsc` to verify type safety +- Run `pnpm run lint` to check code style +- Run `pnpm run test` for affected components +- See `docs/quality-checks.mdc` for complete checklist + +**Commands to run:** ```bash pnpm -w run lint:fix # Fix linting issues diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 0abe33d59..522abfe9a 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -76,7 +76,7 @@ }, "scripts": { "build": "tsdown && pnpm run generate-tool-definitions", - "dev": "tsc -w", + "dev": "pnpm run generate-tool-definitions && tsc -w", "start": "tsx src/index.ts", "prepare": "pnpm run build", "test": "vitest", diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 6fe3e9d93..28b276b1b 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -147,105 +147,49 @@ export async function configureServer({ }; for (const resource of RESOURCES) { - // TODO: this doesnt support any error handling afaict via the spec - if (isTemplateResource(resource)) { - // Template resource handler receives variables instead of RequestHandlerExtra - const templateHandler = async ( - url: URL, - variables: Record, - ) => { - return await startNewTrace(async () => { - return await startSpan( - { - name: `resources/read ${url.toString()}`, - attributes: { - "mcp.resource.name": resource.name, - "mcp.resource.uri": url.toString(), - ...(context.userAgent && { - "user_agent.original": context.userAgent, - }), - }, + // Create the handler function once - it's the same for both resource types + const resourceHandler = async ( + url: URL, + extra: RequestHandlerExtra, + ) => { + return await startNewTrace(async () => { + return await startSpan( + { + name: `resources/read ${url.toString()}`, + attributes: { + "mcp.resource.name": resource.name, + "mcp.resource.uri": url.toString(), + ...(context.userAgent && { + "user_agent.original": context.userAgent, + }), }, - async () => { - if (context.userId) { - setUser({ - id: context.userId, - }); - } - if (context.clientId) { - setTag("client.id", context.clientId); - } - - // Create a minimal RequestHandlerExtra for the handler - const extra: RequestHandlerExtra< - ServerRequest, - ServerNotification - > = { - signal: new AbortController().signal, - requestId: crypto.randomUUID(), - sendNotification: async () => {}, - sendRequest: async () => ({}) as any, - }; + }, + async () => { + if (context.userId) { + setUser({ + id: context.userId, + }); + } + if (context.clientId) { + setTag("client.id", context.clientId); + } - return resource.handler(url, extra); - }, - ); - }); - }; + return resource.handler(url, extra); + }, + ); + }); + }; - server.registerResource( - resource.name, - resource.template, - { - description: resource.description, - mimeType: resource.mimeType, - }, - templateHandler, - ); - } else { - // Regular resource handler - const resourceHandler = async ( - url: URL, - extra: RequestHandlerExtra, - ) => { - return await startNewTrace(async () => { - return await startSpan( - { - name: `resources/read ${url.toString()}`, - attributes: { - "mcp.resource.name": resource.name, - "mcp.resource.uri": url.toString(), - ...(context.userAgent && { - "user_agent.original": context.userAgent, - }), - }, - }, - async () => { - if (context.userId) { - setUser({ - id: context.userId, - }); - } - if (context.clientId) { - setTag("client.id", context.clientId); - } - - return resource.handler(url, extra); - }, - ); - }); - }; - - server.registerResource( - resource.name, - resource.uri, - { - description: resource.description, - mimeType: resource.mimeType, - }, - resourceHandler, - ); - } + // TODO: this doesnt support any error handling afaict via the spec + server.registerResource( + resource.name, + (isTemplateResource(resource) ? resource.template : resource.uri) as any, + { + description: resource.description, + mimeType: resource.mimeType, + }, + resourceHandler as any, + ); } for (const prompt of PROMPT_DEFINITIONS) { From bc55ead6fec76381c949d9d71cdb0629cdf440f5 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 8 Jul 2025 11:57:26 -0700 Subject: [PATCH 5/7] refactor resources --- packages/mcp-server/src/resources.ts | 35 ++++-- packages/mcp-server/src/server.ts | 154 +++++++++++++++++++-------- 2 files changed, 136 insertions(+), 53 deletions(-) diff --git a/packages/mcp-server/src/resources.ts b/packages/mcp-server/src/resources.ts index 8e252d3be..3738351f9 100644 --- a/packages/mcp-server/src/resources.ts +++ b/packages/mcp-server/src/resources.ts @@ -21,30 +21,45 @@ import { ResourceTemplate, type ReadResourceCallback, + type ReadResourceTemplateCallback, } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { UserInputError } from "./errors"; /** - * Resource configuration with handler function + * Resource configuration for regular URI resources */ -export type ResourceConfig = { +export type UriResourceConfig = { name: string; description: string; mimeType: string; + uri: string; handler: ReadResourceCallback; -} & ( - | { uri: string; template?: never } - | { uri?: never; template: ResourceTemplate } -); +}; + +/** + * Resource configuration for URI template resources + */ +export type TemplateResourceConfig = { + name: string; + description: string; + mimeType: string; + template: ResourceTemplate; + handler: ReadResourceCallback; // Changed back to ReadResourceCallback +}; + +/** + * Resource configuration with handler function + */ +export type ResourceConfig = UriResourceConfig | TemplateResourceConfig; /** * Type guard to check if a resource uses a URI template */ export function isTemplateResource( resource: ResourceConfig, -): resource is ResourceConfig & { template: ResourceTemplate } { +): resource is TemplateResourceConfig { return ( "template" in resource && resource.template instanceof ResourceTemplate ); @@ -150,7 +165,7 @@ export const RESOURCES: ResourceConfig[] = [ description: "Use these rules to understand common query parameters when searching Sentry for information.", handler: defaultGitHubHandler, - }, + } as UriResourceConfig, // Platform documentation with dynamic segments { name: "sentry-docs-platform", @@ -170,7 +185,7 @@ export const RESOURCES: ResourceConfig[] = [ mimeType: "text/markdown", description: "Sentry SDK documentation for {platform}", handler: sentryDocsHandler, - }, + } as TemplateResourceConfig, { name: "sentry-docs-platform-guide", template: new ResourceTemplate( @@ -192,5 +207,5 @@ export const RESOURCES: ResourceConfig[] = [ mimeType: "text/markdown", description: "Sentry integration guide for {framework} on {platform}", handler: sentryDocsHandler, - }, + } as TemplateResourceConfig, ]; diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 28b276b1b..1d85e3886 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -23,6 +23,11 @@ import type { ServerRequest, ServerNotification, } from "@modelcontextprotocol/sdk/types.js"; +import type { + ReadResourceCallback, + ReadResourceTemplateCallback, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Variables } from "@modelcontextprotocol/sdk/shared/uriTemplate.js"; import tools from "./tools/index"; import type { ServerContext } from "./types"; import { setTag, setUser, startNewTrace, startSpan } from "@sentry/core"; @@ -118,6 +123,89 @@ function extractMcpParameters(args: Record) { ); } +/** + * Creates a telemetry wrapper for regular URI resource handlers. + * Captures URI access and user context for observability. + */ +function createResourceHandler( + resource: { name: string; handler: ReadResourceCallback }, + context: ServerContext, +): ReadResourceCallback { + return async (uri: URL, extra: RequestHandlerExtra) => { + return await startNewTrace(async () => { + return await startSpan( + { + name: `resources/read ${resource.name}`, + attributes: { + "mcp.resource.name": resource.name, + "mcp.resource.uri": uri.toString(), + ...(context.userAgent && { + "user_agent.original": context.userAgent, + }), + }, + }, + async () => { + if (context.userId) { + setUser({ + id: context.userId, + }); + } + if (context.clientId) { + setTag("client.id", context.clientId); + } + + return resource.handler(uri, extra); + }, + ); + }); + }; +} + +/** + * Creates a telemetry wrapper for URI template resource handlers. + * Captures template parameters and user context for observability. + */ +function createTemplateResourceHandler( + resource: { name: string; handler: ReadResourceCallback }, + context: ServerContext, +): ReadResourceTemplateCallback { + return async ( + uri: URL, + variables: Variables, + extra: RequestHandlerExtra, + ) => { + return await startNewTrace(async () => { + return await startSpan( + { + name: `resources/read ${resource.name}`, + attributes: { + "mcp.resource.name": resource.name, + "mcp.resource.uri": uri.toString(), + ...(context.userAgent && { + "user_agent.original": context.userAgent, + }), + ...extractMcpParameters(variables), + }, + }, + async () => { + if (context.userId) { + setUser({ + id: context.userId, + }); + } + if (context.clientId) { + setTag("client.id", context.clientId); + } + + // The MCP SDK has already constructed the URI from the template and variables + // We just need to call the handler with the constructed URI + return resource.handler(uri, extra); + }, + ); + }); + }; +} + /** * Configures an MCP server with all tools, prompts, resources, and telemetry. * @@ -147,49 +235,29 @@ export async function configureServer({ }; for (const resource of RESOURCES) { - // Create the handler function once - it's the same for both resource types - const resourceHandler = async ( - url: URL, - extra: RequestHandlerExtra, - ) => { - return await startNewTrace(async () => { - return await startSpan( - { - name: `resources/read ${url.toString()}`, - attributes: { - "mcp.resource.name": resource.name, - "mcp.resource.uri": url.toString(), - ...(context.userAgent && { - "user_agent.original": context.userAgent, - }), - }, - }, - async () => { - if (context.userId) { - setUser({ - id: context.userId, - }); - } - if (context.clientId) { - setTag("client.id", context.clientId); - } - - return resource.handler(url, extra); - }, - ); - }); - }; - - // TODO: this doesnt support any error handling afaict via the spec - server.registerResource( - resource.name, - (isTemplateResource(resource) ? resource.template : resource.uri) as any, - { - description: resource.description, - mimeType: resource.mimeType, - }, - resourceHandler as any, - ); + if (isTemplateResource(resource)) { + // Handle URI template resources + server.registerResource( + resource.name, + resource.template, + { + description: resource.description, + mimeType: resource.mimeType, + }, + createTemplateResourceHandler(resource, context), + ); + } else { + // Handle regular URI resources + server.registerResource( + resource.name, + resource.uri, + { + description: resource.description, + mimeType: resource.mimeType, + }, + createResourceHandler(resource, context), + ); + } } for (const prompt of PROMPT_DEFINITIONS) { From 8aadc99555ae3c138c9dbc2a42982a208737a270 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 8 Jul 2025 12:27:27 -0700 Subject: [PATCH 6/7] Improve tool output --- .../mcp-cloudflare/src/client/pages/home.tsx | 65 ++++++++++--------- .../scripts/generate-tool-definitions.ts | 45 +++++++++---- 2 files changed, 64 insertions(+), 46 deletions(-) diff --git a/packages/mcp-cloudflare/src/client/pages/home.tsx b/packages/mcp-cloudflare/src/client/pages/home.tsx index 9314f3f0d..360ef1b07 100644 --- a/packages/mcp-cloudflare/src/client/pages/home.tsx +++ b/packages/mcp-cloudflare/src/client/pages/home.tsx @@ -152,38 +152,39 @@ export default function Home({ onChatClick }: HomeProps) { organization, otherwise you should mention it in the prompt. - {TOOL_DEFINITIONS.sort((a, b) => a.name.localeCompare(b.name)).map( - (tool) => ( - - - {tool.name} - - - -

{tool.description.split("\n")[0]}

-
- {tool.inputSchema ? ( -
- {Object.entries(tool.inputSchema).map( - ([key, value]: [string, any]) => { - return ( -
-
- {key} -
-
- {value.description} -
-
- ); - }, - )} -
- ) : null} -
-
- ), - )} + {TOOL_DEFINITIONS.sort((a: any, b: any) => + a.name.localeCompare(b.name), + ).map((tool: any) => ( + + + {tool.name} + + + +

{tool.description.split("\n")[0]}

+
+ {tool.inputSchema && + Object.keys(tool.inputSchema).length > 0 ? ( +
+ {Object.entries(tool.inputSchema).map( + ([key, property]: [string, any]) => { + return ( +
+
+ {key} +
+
+ {property.description} +
+
+ ); + }, + )} +
+ ) : null} +
+
+ ))}
diff --git a/packages/mcp-server/scripts/generate-tool-definitions.ts b/packages/mcp-server/scripts/generate-tool-definitions.ts index 7e52405c3..584132942 100644 --- a/packages/mcp-server/scripts/generate-tool-definitions.ts +++ b/packages/mcp-server/scripts/generate-tool-definitions.ts @@ -23,26 +23,24 @@ const __dirname = path.dirname(__filename); const tools = await import("../src/tools/index.js"); /** - * Convert Zod schema object to JSON Schema + * Convert Zod schema object to JSON Schema properties */ function convertInputSchemaToJsonSchema(inputSchema: Record) { if (!inputSchema || Object.keys(inputSchema).length === 0) { return {}; } - // Convert the inputSchema object to a Zod object schema, then to JSON Schema - const zodObjectSchema = z.object(inputSchema); - const jsonSchema = zodToJsonSchema(zodObjectSchema, { - name: "ToolInputSchema", - $refStrategy: "none", // Don't use $ref for cleaner output - }); + const properties: Record = {}; - // If there are definitions, extract from there, otherwise use direct properties - if (jsonSchema.definitions?.ToolInputSchema?.properties) { - return jsonSchema.definitions.ToolInputSchema.properties; + // Convert each individual Zod schema to JSON Schema + for (const [key, zodSchema] of Object.entries(inputSchema)) { + const jsonSchema = zodToJsonSchema(zodSchema, { + $refStrategy: "none", // Don't use $ref for cleaner output + }); + properties[key] = jsonSchema; } - return jsonSchema.properties || {}; + return properties; } /** @@ -105,11 +103,30 @@ async function main() { // Write TypeScript declaration file const dtsPath = path.join(distDir, "toolDefinitions.d.ts"); - const dtsContent = `declare const toolDefinitions: Array<{ + const dtsContent = `// JSON Schema property type definition +interface JsonSchemaProperty { + type: string; + description: string; + enum?: string[]; + default?: any; + format?: string; + minLength?: number; + maxLength?: number; + minimum?: number; + maximum?: number; +} + +// Tool definition interface with proper JSON Schema types +interface ToolDefinition { name: string; description: string; - inputSchema: Record; -}>; + inputSchema: Record; +} + +// Array of tool definitions with proper typing +type ToolDefinitions = ToolDefinition[]; + +declare const toolDefinitions: ToolDefinitions; export default toolDefinitions;`; fs.writeFileSync(dtsPath, dtsContent); From c4cbbc2fe8de0388c40842be604c360d8a9e6e94 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 8 Jul 2025 12:36:51 -0700 Subject: [PATCH 7/7] infer types --- .../mcp-cloudflare/src/client/pages/home.tsx | 66 +++++++++---------- .../scripts/generate-tool-definitions.ts | 6 +- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/mcp-cloudflare/src/client/pages/home.tsx b/packages/mcp-cloudflare/src/client/pages/home.tsx index 360ef1b07..7106eebd4 100644 --- a/packages/mcp-cloudflare/src/client/pages/home.tsx +++ b/packages/mcp-cloudflare/src/client/pages/home.tsx @@ -152,39 +152,39 @@ export default function Home({ onChatClick }: HomeProps) { organization, otherwise you should mention it in the prompt. - {TOOL_DEFINITIONS.sort((a: any, b: any) => - a.name.localeCompare(b.name), - ).map((tool: any) => ( - - - {tool.name} - - - -

{tool.description.split("\n")[0]}

-
- {tool.inputSchema && - Object.keys(tool.inputSchema).length > 0 ? ( -
- {Object.entries(tool.inputSchema).map( - ([key, property]: [string, any]) => { - return ( -
-
- {key} -
-
- {property.description} -
-
- ); - }, - )} -
- ) : null} -
-
- ))} + {TOOL_DEFINITIONS.sort((a, b) => a.name.localeCompare(b.name)).map( + (tool) => ( + + + {tool.name} + + + +

{tool.description.split("\n")[0]}

+
+ {tool.inputSchema && + Object.keys(tool.inputSchema).length > 0 ? ( +
+ {Object.entries(tool.inputSchema).map( + ([key, property]) => { + return ( +
+
+ {key} +
+
+ {property.description} +
+
+ ); + }, + )} +
+ ) : null} +
+
+ ), + )}
diff --git a/packages/mcp-server/scripts/generate-tool-definitions.ts b/packages/mcp-server/scripts/generate-tool-definitions.ts index 584132942..02cb8c2d0 100644 --- a/packages/mcp-server/scripts/generate-tool-definitions.ts +++ b/packages/mcp-server/scripts/generate-tool-definitions.ts @@ -104,7 +104,7 @@ async function main() { // Write TypeScript declaration file const dtsPath = path.join(distDir, "toolDefinitions.d.ts"); const dtsContent = `// JSON Schema property type definition -interface JsonSchemaProperty { +export interface JsonSchemaProperty { type: string; description: string; enum?: string[]; @@ -117,14 +117,14 @@ interface JsonSchemaProperty { } // Tool definition interface with proper JSON Schema types -interface ToolDefinition { +export interface ToolDefinition { name: string; description: string; inputSchema: Record; } // Array of tool definitions with proper typing -type ToolDefinitions = ToolDefinition[]; +export type ToolDefinitions = ToolDefinition[]; declare const toolDefinitions: ToolDefinitions; export default toolDefinitions;`;