From d0c927d05ba1cc47c6d45cf57dca94e81bdb7eba Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 3 Apr 2026 11:03:06 -0700 Subject: [PATCH 1/2] ref(tools): Merge search/list tool pairs into unified tools Combine search_issues+list_issues, search_events+list_events, and search_issue_events+list_issue_events into single tools. Each tool now accepts both direct Sentry query syntax (via query/sort params) and optional natural language search (via naturalLanguageQuery param). When naturalLanguageQuery is provided and an embedded agent provider is configured, the agent refines the intent into correct Sentry params. When omitted, the direct params are used as-is with no agent overhead. This eliminates the mandatory agent round-trip for simple queries like iterating through N issues. Removes the AGENT_DEPENDENT_TOOLS/SIMPLE_REPLACEMENT_TOOLS mutual exclusivity system from server.ts. The 3 list_* tools and their directories are deleted. Tool count drops by 3. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/mcp-core/src/server.test.ts | 5 - packages/mcp-core/src/server.ts | 21 +- packages/mcp-core/src/skillDefinitions.json | 62 +--- packages/mcp-core/src/toolDefinitions.json | 304 +++++------------- packages/mcp-core/src/tools/index.ts | 27 -- .../mcp-core/src/tools/list-events/index.ts | 204 ------------ .../src/tools/list-events/list-events.test.ts | 54 ---- .../src/tools/list-issue-events/index.ts | 160 --------- .../list-issue-events.test.ts | 137 -------- .../mcp-core/src/tools/list-issues/index.ts | 111 ------- .../src/tools/list-issues/list-issues.test.ts | 91 ------ .../mcp-core/src/tools/search-events.test.ts | 142 +++++++- .../src/tools/search-events/handler.ts | 216 +++++++------ .../src/tools/search-issue-events.test.ts | 120 ++++++- .../src/tools/search-issue-events/handler.ts | 159 +++++---- .../mcp-core/src/tools/search-issues.test.ts | 130 ++++++-- .../src/tools/search-issues/handler.ts | 153 +++++---- .../src/tools/use-sentry/handler.test.ts | 4 +- .../mcp-core/src/tools/use-sentry/handler.ts | 10 +- .../src/evals/list-issues.eval.ts | 23 +- packages/mcp-server/src/index.ts | 7 +- .../agents/sentry-mcp.md | 5 +- plugins/sentry-mcp/agents/sentry-mcp.md | 5 +- 23 files changed, 768 insertions(+), 1382 deletions(-) delete mode 100644 packages/mcp-core/src/tools/list-events/index.ts delete mode 100644 packages/mcp-core/src/tools/list-events/list-events.test.ts delete mode 100644 packages/mcp-core/src/tools/list-issue-events/index.ts delete mode 100644 packages/mcp-core/src/tools/list-issue-events/list-issue-events.test.ts delete mode 100644 packages/mcp-core/src/tools/list-issues/index.ts delete mode 100644 packages/mcp-core/src/tools/list-issues/list-issues.test.ts diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 74ecfaf95..43fc2b8c7 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -11,11 +11,6 @@ vi.mock("@sentry/core", () => ({ wrapMcpServerWithSentry: vi.fn((server) => server), })); -// Mock the agent provider factory -vi.mock("./internal/agents/provider-factory", () => ({ - hasAgentProvider: vi.fn(() => false), -})); - /** * Helper to get registered tool names from an McpServer. * Uses the internal _registeredTools object which exists directly on McpServer instances. diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index 94a7be562..b24774b81 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -25,10 +25,7 @@ import type { ServerRequest, ServerNotification, } from "@modelcontextprotocol/sdk/types.js"; -import tools, { - AGENT_DEPENDENT_TOOLS, - SIMPLE_REPLACEMENT_TOOLS, -} from "./tools/index"; +import tools from "./tools/index"; import { type ToolConfig, resolveDescription, @@ -50,7 +47,6 @@ import { getConstraintParametersToInject, getConstraintKeysToFilter, } from "./internal/constraint-helpers"; -import { hasAgentProvider } from "./internal/agents/provider-factory"; /** * Creates and configures a complete MCP server with Sentry instrumentation. @@ -166,21 +162,6 @@ function configureServer({ ? { use_sentry: tools.use_sentry } : (customTools ?? tools); - // Filter tools based on agent provider availability - // Skip filtering in agent mode (use_sentry handles all tools internally) or when custom tools are provided - if (!agentMode && !customTools) { - const hasAgent = hasAgentProvider(); - const toolsToExclude = new Set( - hasAgent ? SIMPLE_REPLACEMENT_TOOLS : AGENT_DEPENDENT_TOOLS, - ); - - toolsToRegister = Object.fromEntries( - Object.entries(toolsToRegister).filter( - ([key]) => !toolsToExclude.has(key), - ), - ) as typeof toolsToRegister; - } - // Filter tools based on public visibility and experimental mode // (applies to all tools, including custom) // Skip in agent mode (use_sentry handles filtering internally) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 982424999..bd083f98a 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -5,7 +5,7 @@ "description": "Search for errors, analyze traces, and explore event details", "defaultEnabled": true, "order": 1, - "toolCount": 16, + "toolCount": 13, "tools": [ { "name": "find_organizations", @@ -52,34 +52,19 @@ "description": "Fetch a Sentry resource by URL or by type and ID.\n\n\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n", "requiredScopes": ["event:read"] }, - { - "name": "list_events", - "description": "Search events using Sentry query syntax directly (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_events instead.\n\nDatasets:\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, traces, AI/LLM calls\n\nQuery Syntax Examples:\n- message:\"connection timeout\"\n- level:error\n- span.op:http.client span.status_code:500\n- log.level:error\n- transaction:/api/users\n\n\nlist_events(organizationSlug='my-org', dataset='errors', query='level:error')\nlist_events(organizationSlug='my-org', dataset='spans', query='span.op:db')\nlist_events(organizationSlug='my-org', dataset='logs', query='severity:error')\nlist_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", - "requiredScopes": ["event:read"] - }, - { - "name": "list_issue_events", - "description": "List events within a specific issue using Sentry query syntax (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_issue_events instead.\n\nCommon Query Filters:\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n- user.email:alice@example.com - Filter by user email\n- timestamp:>2024-01-01 - Filter by timestamp\n\n\nlist_issue_events(issueUrl='https://sentry.io/organizations/my-org/issues/123/', query='environment:production')\nlist_issue_events(issueId='MCP-41', organizationSlug='my-org', query='release:v1.0.0')\nlist_issue_events(issueId='PROJECT-123', organizationSlug='my-org', statsPeriod='1h')\n\n\n\n- Use issueUrl for convenience (includes org + issue ID)\n- Or provide both issueId and organizationSlug\n- The query filters events WITHIN the issue, no need for issue: prefix\n", - "requiredScopes": ["event:read"] - }, - { - "name": "list_issues", - "description": "List issues using Sentry query syntax directly (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_issues instead.\n\nCommon Query Syntax:\n- is:unresolved - Show unresolved issues only\n- is:unassigned - Show unassigned issues\n- level:error - Filter by error level\n- firstSeen:-24h - First seen in last 24 hours\n- lastSeen:-1h - Last seen in last hour\n- has:user - Issues with user context\n- user.email:user@example.com - Filter by user email\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n\nCombine queries: is:unresolved is:unassigned level:error\n\n\nlist_issues(organizationSlug='my-org', query='is:unresolved is:unassigned')\nlist_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', sort='freq')\nlist_issues(organizationSlug='my-org', projectSlugOrId='my-project', query='is:unresolved')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", - "requiredScopes": ["event:read"] - }, { "name": "search_events", - "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'count of issues', 'total tokens'\n2. Individual events with timestamps: 'show me error logs from last hour'\n\nUSE THIS FOR ALL COUNTS/STATISTICS:\n- 'how many errors today' → returns count\n- 'count of database failures' → returns count\n- 'total number of issues' → returns count\n- 'average response time' → returns avg()\n- 'sum of tokens used' → returns sum()\n\nALSO USE FOR INDIVIDUAL EVENTS:\n- 'error logs from last hour' → returns event list\n- 'database errors with timestamps' → returns event list\n- 'trace spans for slow API calls' → returns span list\n\nDataset Selection (AI automatically chooses):\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, AI/LLM calls, token usage\n\nDO NOT USE for grouped issue lists → use search_issues\n\n\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='count of database failures this week')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='total tokens used by model')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='error logs from the last hour')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n", + "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine dataset, query, fields, and sort,\nor provide these directly with Sentry search syntax.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n\nDatasets:\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, traces, AI/LLM calls\n\nDO NOT USE for grouped issue lists → use search_issues\n\n\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', query='level:error')\nsearch_events(organizationSlug='my-org', dataset='errors', query='level:error', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", "requiredScopes": ["event:read"] }, { "name": "search_issue_events", - "description": "Search and filter events within a specific issue using natural language queries.\n\nUse this to filter events by time, environment, release, user, trace ID, or other tags. The tool automatically constrains results to the specified issue.\n\nFor cross-issue searches use search_issues. For single issue or event details use get_sentry_resource.\n\n\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', naturalLanguageQuery='from last hour')\nsearch_issue_events(issueUrl='https://sentry.io/.../issues/123/', naturalLanguageQuery='production with release v1.0')\n", + "description": "Search and filter events within a specific issue.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine the correct filters,\nor use `query` and `sort` directly with Sentry search syntax.\n\nThe tool automatically constrains results to the specified issue.\n\nCommon Query Filters:\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n- user.email:alice@example.com - Filter by user\n- trace:TRACE_ID - Filter by trace ID\n\nFor cross-issue searches use search_issues. For single issue or event details use get_sentry_resource.\n\n\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', naturalLanguageQuery='from last hour')\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', query='environment:production')\nsearch_issue_events(issueUrl='https://sentry.io/.../issues/123/', query='release:v1.0.0', statsPeriod='7d')\n", "requiredScopes": ["event:read"] }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nUses AI to translate natural language queries into Sentry issue search syntax.\nReturns grouped issues with metadata like title, status, and user count.\n\nUSE THIS TOOL WHEN USERS WANT:\n- A LIST of issues: 'show me issues', 'what problems do we have'\n- Filtered issue lists: 'unresolved issues', 'critical bugs'\n- Issues by impact: 'errors affecting more than 100 users'\n- Issues by assignment: 'issues assigned to me'\n- User feedback: 'show me user feedback', 'feedback from last week'\n\nDO NOT USE FOR COUNTS/AGGREGATIONS:\n- 'how many errors' → use search_events\n- 'count of issues' → use search_events\n- 'total number of errors today' → use search_events\n- 'sum/average/statistics' → use search_events\n\nALSO DO NOT USE FOR:\n- Individual error events with timestamps → use search_events\n- Details about a specific issue ID or Sentry issue URL → use get_sentry_resource\n\nREMEMBER: This tool returns a LIST of issues, not counts or statistics!\n\n\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='user feedback from production')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine the correct query and sort params,\nor use `query` and `sort` directly with Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assignedOrSuggested:me\n- issueCategory:feedback\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_events\nDO NOT USE FOR individual events with timestamps → use search_events\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { @@ -95,7 +80,7 @@ "description": "Sentry's AI debugger that helps you analyze, root cause, and fix issues", "defaultEnabled": true, "order": 2, - "toolCount": 9, + "toolCount": 7, "tools": [ { "name": "analyze_issue_with_seer", @@ -117,24 +102,14 @@ "description": "Fetch a Sentry resource by URL or by type and ID.\n\n\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n", "requiredScopes": ["event:read"] }, - { - "name": "list_events", - "description": "Search events using Sentry query syntax directly (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_events instead.\n\nDatasets:\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, traces, AI/LLM calls\n\nQuery Syntax Examples:\n- message:\"connection timeout\"\n- level:error\n- span.op:http.client span.status_code:500\n- log.level:error\n- transaction:/api/users\n\n\nlist_events(organizationSlug='my-org', dataset='errors', query='level:error')\nlist_events(organizationSlug='my-org', dataset='spans', query='span.op:db')\nlist_events(organizationSlug='my-org', dataset='logs', query='severity:error')\nlist_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", - "requiredScopes": ["event:read"] - }, - { - "name": "list_issues", - "description": "List issues using Sentry query syntax directly (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_issues instead.\n\nCommon Query Syntax:\n- is:unresolved - Show unresolved issues only\n- is:unassigned - Show unassigned issues\n- level:error - Filter by error level\n- firstSeen:-24h - First seen in last 24 hours\n- lastSeen:-1h - Last seen in last hour\n- has:user - Issues with user context\n- user.email:user@example.com - Filter by user email\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n\nCombine queries: is:unresolved is:unassigned level:error\n\n\nlist_issues(organizationSlug='my-org', query='is:unresolved is:unassigned')\nlist_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', sort='freq')\nlist_issues(organizationSlug='my-org', projectSlugOrId='my-project', query='is:unresolved')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", - "requiredScopes": ["event:read"] - }, { "name": "search_events", - "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'count of issues', 'total tokens'\n2. Individual events with timestamps: 'show me error logs from last hour'\n\nUSE THIS FOR ALL COUNTS/STATISTICS:\n- 'how many errors today' → returns count\n- 'count of database failures' → returns count\n- 'total number of issues' → returns count\n- 'average response time' → returns avg()\n- 'sum of tokens used' → returns sum()\n\nALSO USE FOR INDIVIDUAL EVENTS:\n- 'error logs from last hour' → returns event list\n- 'database errors with timestamps' → returns event list\n- 'trace spans for slow API calls' → returns span list\n\nDataset Selection (AI automatically chooses):\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, AI/LLM calls, token usage\n\nDO NOT USE for grouped issue lists → use search_issues\n\n\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='count of database failures this week')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='total tokens used by model')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='error logs from the last hour')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n", + "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine dataset, query, fields, and sort,\nor provide these directly with Sentry search syntax.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n\nDatasets:\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, traces, AI/LLM calls\n\nDO NOT USE for grouped issue lists → use search_issues\n\n\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', query='level:error')\nsearch_events(organizationSlug='my-org', dataset='errors', query='level:error', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", "requiredScopes": ["event:read"] }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nUses AI to translate natural language queries into Sentry issue search syntax.\nReturns grouped issues with metadata like title, status, and user count.\n\nUSE THIS TOOL WHEN USERS WANT:\n- A LIST of issues: 'show me issues', 'what problems do we have'\n- Filtered issue lists: 'unresolved issues', 'critical bugs'\n- Issues by impact: 'errors affecting more than 100 users'\n- Issues by assignment: 'issues assigned to me'\n- User feedback: 'show me user feedback', 'feedback from last week'\n\nDO NOT USE FOR COUNTS/AGGREGATIONS:\n- 'how many errors' → use search_events\n- 'count of issues' → use search_events\n- 'total number of errors today' → use search_events\n- 'sum/average/statistics' → use search_events\n\nALSO DO NOT USE FOR:\n- Individual error events with timestamps → use search_events\n- Details about a specific issue ID or Sentry issue URL → use get_sentry_resource\n\nREMEMBER: This tool returns a LIST of issues, not counts or statistics!\n\n\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='user feedback from production')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine the correct query and sort params,\nor use `query` and `sort` directly with Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assignedOrSuggested:me\n- issueCategory:feedback\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_events\nDO NOT USE FOR individual events with timestamps → use search_events\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { @@ -185,7 +160,7 @@ "description": "Resolve, assign, and update issues", "defaultEnabled": false, "order": 4, - "toolCount": 12, + "toolCount": 9, "tools": [ { "name": "find_organizations", @@ -207,34 +182,19 @@ "description": "Fetch a Sentry resource by URL or by type and ID.\n\n\n### From a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\n\n### Breadcrumbs from a Sentry URL\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/', resourceType='breadcrumbs')\n\n### By type and ID\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\n", "requiredScopes": ["event:read"] }, - { - "name": "list_events", - "description": "Search events using Sentry query syntax directly (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_events instead.\n\nDatasets:\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, traces, AI/LLM calls\n\nQuery Syntax Examples:\n- message:\"connection timeout\"\n- level:error\n- span.op:http.client span.status_code:500\n- log.level:error\n- transaction:/api/users\n\n\nlist_events(organizationSlug='my-org', dataset='errors', query='level:error')\nlist_events(organizationSlug='my-org', dataset='spans', query='span.op:db')\nlist_events(organizationSlug='my-org', dataset='logs', query='severity:error')\nlist_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", - "requiredScopes": ["event:read"] - }, - { - "name": "list_issue_events", - "description": "List events within a specific issue using Sentry query syntax (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_issue_events instead.\n\nCommon Query Filters:\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n- user.email:alice@example.com - Filter by user email\n- timestamp:>2024-01-01 - Filter by timestamp\n\n\nlist_issue_events(issueUrl='https://sentry.io/organizations/my-org/issues/123/', query='environment:production')\nlist_issue_events(issueId='MCP-41', organizationSlug='my-org', query='release:v1.0.0')\nlist_issue_events(issueId='PROJECT-123', organizationSlug='my-org', statsPeriod='1h')\n\n\n\n- Use issueUrl for convenience (includes org + issue ID)\n- Or provide both issueId and organizationSlug\n- The query filters events WITHIN the issue, no need for issue: prefix\n", - "requiredScopes": ["event:read"] - }, - { - "name": "list_issues", - "description": "List issues using Sentry query syntax directly (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_issues instead.\n\nCommon Query Syntax:\n- is:unresolved - Show unresolved issues only\n- is:unassigned - Show unassigned issues\n- level:error - Filter by error level\n- firstSeen:-24h - First seen in last 24 hours\n- lastSeen:-1h - Last seen in last hour\n- has:user - Issues with user context\n- user.email:user@example.com - Filter by user email\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n\nCombine queries: is:unresolved is:unassigned level:error\n\n\nlist_issues(organizationSlug='my-org', query='is:unresolved is:unassigned')\nlist_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', sort='freq')\nlist_issues(organizationSlug='my-org', projectSlugOrId='my-project', query='is:unresolved')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", - "requiredScopes": ["event:read"] - }, { "name": "search_events", - "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'count of issues', 'total tokens'\n2. Individual events with timestamps: 'show me error logs from last hour'\n\nUSE THIS FOR ALL COUNTS/STATISTICS:\n- 'how many errors today' → returns count\n- 'count of database failures' → returns count\n- 'total number of issues' → returns count\n- 'average response time' → returns avg()\n- 'sum of tokens used' → returns sum()\n\nALSO USE FOR INDIVIDUAL EVENTS:\n- 'error logs from last hour' → returns event list\n- 'database errors with timestamps' → returns event list\n- 'trace spans for slow API calls' → returns span list\n\nDataset Selection (AI automatically chooses):\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, AI/LLM calls, token usage\n\nDO NOT USE for grouped issue lists → use search_issues\n\n\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='count of database failures this week')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='total tokens used by model')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='error logs from the last hour')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n", + "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine dataset, query, fields, and sort,\nor provide these directly with Sentry search syntax.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n\nDatasets:\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, traces, AI/LLM calls\n\nDO NOT USE for grouped issue lists → use search_issues\n\n\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', query='level:error')\nsearch_events(organizationSlug='my-org', dataset='errors', query='level:error', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", "requiredScopes": ["event:read"] }, { "name": "search_issue_events", - "description": "Search and filter events within a specific issue using natural language queries.\n\nUse this to filter events by time, environment, release, user, trace ID, or other tags. The tool automatically constrains results to the specified issue.\n\nFor cross-issue searches use search_issues. For single issue or event details use get_sentry_resource.\n\n\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', naturalLanguageQuery='from last hour')\nsearch_issue_events(issueUrl='https://sentry.io/.../issues/123/', naturalLanguageQuery='production with release v1.0')\n", + "description": "Search and filter events within a specific issue.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine the correct filters,\nor use `query` and `sort` directly with Sentry search syntax.\n\nThe tool automatically constrains results to the specified issue.\n\nCommon Query Filters:\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n- user.email:alice@example.com - Filter by user\n- trace:TRACE_ID - Filter by trace ID\n\nFor cross-issue searches use search_issues. For single issue or event details use get_sentry_resource.\n\n\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', naturalLanguageQuery='from last hour')\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', query='environment:production')\nsearch_issue_events(issueUrl='https://sentry.io/.../issues/123/', query='release:v1.0.0', statsPeriod='7d')\n", "requiredScopes": ["event:read"] }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nUses AI to translate natural language queries into Sentry issue search syntax.\nReturns grouped issues with metadata like title, status, and user count.\n\nUSE THIS TOOL WHEN USERS WANT:\n- A LIST of issues: 'show me issues', 'what problems do we have'\n- Filtered issue lists: 'unresolved issues', 'critical bugs'\n- Issues by impact: 'errors affecting more than 100 users'\n- Issues by assignment: 'issues assigned to me'\n- User feedback: 'show me user feedback', 'feedback from last week'\n\nDO NOT USE FOR COUNTS/AGGREGATIONS:\n- 'how many errors' → use search_events\n- 'count of issues' → use search_events\n- 'total number of errors today' → use search_events\n- 'sum/average/statistics' → use search_events\n\nALSO DO NOT USE FOR:\n- Individual error events with timestamps → use search_events\n- Details about a specific issue ID or Sentry issue URL → use get_sentry_resource\n\nREMEMBER: This tool returns a LIST of issues, not counts or statistics!\n\n\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='user feedback from production')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine the correct query and sort params,\nor use `query` and `sort` directly with Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assignedOrSuggested:me\n- issueCategory:feedback\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_events\nDO NOT USE FOR individual events with timestamps → use search_events\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index d8ea4e682..882632758 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -589,221 +589,6 @@ }, "requiredScopes": ["event:read"] }, - { - "name": "list_events", - "description": "Search events using Sentry query syntax directly (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_events instead.\n\nDatasets:\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, traces, AI/LLM calls\n\nQuery Syntax Examples:\n- message:\"connection timeout\"\n- level:error\n- span.op:http.client span.status_code:500\n- log.level:error\n- transaction:/api/users\n\n\nlist_events(organizationSlug='my-org', dataset='errors', query='level:error')\nlist_events(organizationSlug='my-org', dataset='spans', query='span.op:db')\nlist_events(organizationSlug='my-org', dataset='logs', query='severity:error')\nlist_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", - "inputSchema": { - "type": "object", - "properties": { - "organizationSlug": { - "type": "string", - "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." - }, - "dataset": { - "type": "string", - "enum": ["errors", "logs", "spans"], - "default": "errors", - "description": "Dataset to query: errors (exceptions), logs, or spans (traces)" - }, - "query": { - "type": "string", - "default": "", - "description": "Sentry event search query syntax (empty for all events)" - }, - "fields": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics." - }, - "sort": { - "type": "string", - "default": "-timestamp", - "description": "Sort field (prefix with - for descending). Use -count() for aggregations." - }, - "projectSlug": { - "anyOf": [ - { - "type": "string", - "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." - }, - { - "type": "null" - } - ], - "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool.", - "default": null - }, - "statsPeriod": { - "type": "string", - "default": "14d", - "description": "Time period: 1h, 24h, 7d, 14d, 30d, etc." - }, - "limit": { - "type": "number", - "minimum": 1, - "maximum": 100, - "default": 10, - "description": "Maximum number of results to return (1-100)" - }, - "regionUrl": { - "anyOf": [ - { - "type": "string", - "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." - }, - { - "type": "null" - } - ], - "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool.", - "default": null - } - }, - "required": ["organizationSlug"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "requiredScopes": ["event:read"] - }, - { - "name": "list_issue_events", - "description": "List events within a specific issue using Sentry query syntax (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_issue_events instead.\n\nCommon Query Filters:\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n- user.email:alice@example.com - Filter by user email\n- timestamp:>2024-01-01 - Filter by timestamp\n\n\nlist_issue_events(issueUrl='https://sentry.io/organizations/my-org/issues/123/', query='environment:production')\nlist_issue_events(issueId='MCP-41', organizationSlug='my-org', query='release:v1.0.0')\nlist_issue_events(issueId='PROJECT-123', organizationSlug='my-org', statsPeriod='1h')\n\n\n\n- Use issueUrl for convenience (includes org + issue ID)\n- Or provide both issueId and organizationSlug\n- The query filters events WITHIN the issue, no need for issue: prefix\n", - "inputSchema": { - "type": "object", - "properties": { - "organizationSlug": { - "anyOf": [ - { - "type": "string", - "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." - }, - { - "type": "null" - } - ], - "description": "Organization slug. Required when using issueId. Not needed when using issueUrl.", - "default": null - }, - "issueId": { - "type": "string", - "description": "Issue ID (e.g., 'MCP-41', 'PROJECT-123'). Requires organizationSlug." - }, - "issueUrl": { - "type": "string", - "format": "uri", - "description": "Full Sentry issue URL. Includes both organization and issue ID." - }, - "query": { - "type": "string", - "default": "", - "description": "Sentry event search query syntax (empty for all events)" - }, - "sort": { - "type": "string", - "default": "-timestamp", - "description": "Sort field (prefix with - for descending). Default: -timestamp" - }, - "statsPeriod": { - "type": "string", - "default": "14d", - "description": "Time period: 1h, 24h, 7d, 14d, 30d, etc." - }, - "limit": { - "type": "number", - "minimum": 1, - "maximum": 100, - "default": 50, - "description": "Maximum number of events to return (1-100)" - }, - "regionUrl": { - "anyOf": [ - { - "type": "string", - "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." - }, - { - "type": "null" - } - ], - "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool.", - "default": null - } - }, - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "requiredScopes": ["event:read"] - }, - { - "name": "list_issues", - "description": "List issues using Sentry query syntax directly (no AI/LLM required).\n\nUse this tool when:\n- You know Sentry query syntax already\n- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)\n- You want precise control over the query\n\nFor natural language queries, use search_issues instead.\n\nCommon Query Syntax:\n- is:unresolved - Show unresolved issues only\n- is:unassigned - Show unassigned issues\n- level:error - Filter by error level\n- firstSeen:-24h - First seen in last 24 hours\n- lastSeen:-1h - Last seen in last hour\n- has:user - Issues with user context\n- user.email:user@example.com - Filter by user email\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n\nCombine queries: is:unresolved is:unassigned level:error\n\n\nlist_issues(organizationSlug='my-org', query='is:unresolved is:unassigned')\nlist_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', sort='freq')\nlist_issues(organizationSlug='my-org', projectSlugOrId='my-project', query='is:unresolved')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", - "inputSchema": { - "type": "object", - "properties": { - "organizationSlug": { - "type": "string", - "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." - }, - "query": { - "type": "string", - "default": "is:unresolved", - "description": "Sentry issue search query syntax" - }, - "projectSlugOrId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Filter by project slug or numeric ID (optional)" - }, - "sort": { - "type": "string", - "enum": ["date", "freq", "new", "user"], - "default": "date", - "description": "Sort order: date (last seen), freq (frequency), new (first seen), user (user count)" - }, - "limit": { - "type": "number", - "minimum": 1, - "maximum": 100, - "default": 10, - "description": "Maximum number of issues to return (1-100)" - }, - "regionUrl": { - "anyOf": [ - { - "type": "string", - "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." - }, - { - "type": "null" - } - ], - "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool.", - "default": null - } - }, - "required": ["organizationSlug"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "requiredScopes": ["event:read"] - }, { "name": "search_docs", "description": "Search Sentry documentation for SDK setup, instrumentation, and configuration guidance.\n\nUse this tool when you need to:\n- Set up Sentry SDK or framework integrations (Django, Flask, Express, Next.js, etc.)\n- Configure features like performance monitoring, error sampling, or release tracking\n- Implement custom instrumentation (spans, transactions, breadcrumbs)\n- Configure data scrubbing, filtering, or sampling rules\n\nReturns snippets only. Use `get_doc(path='...')` to fetch full documentation content.\n\n\n```\nsearch_docs(query='Django setup configuration SENTRY_DSN', guide='python/django')\nsearch_docs(query='source maps webpack upload', guide='javascript/nextjs')\n```\n\n\n\n- Use guide parameter to filter to specific technologies (e.g., 'javascript/nextjs')\n- Include specific feature names like 'beforeSend', 'tracesSampleRate', 'SENTRY_DSN'\n", @@ -973,7 +758,7 @@ }, { "name": "search_events", - "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'count of issues', 'total tokens'\n2. Individual events with timestamps: 'show me error logs from last hour'\n\nUSE THIS FOR ALL COUNTS/STATISTICS:\n- 'how many errors today' → returns count\n- 'count of database failures' → returns count\n- 'total number of issues' → returns count\n- 'average response time' → returns avg()\n- 'sum of tokens used' → returns sum()\n\nALSO USE FOR INDIVIDUAL EVENTS:\n- 'error logs from last hour' → returns event list\n- 'database errors with timestamps' → returns event list\n- 'trace spans for slow API calls' → returns span list\n\nDataset Selection (AI automatically chooses):\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, AI/LLM calls, token usage\n\nDO NOT USE for grouped issue lists → use search_issues\n\n\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='count of database failures this week')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='total tokens used by model')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='error logs from the last hour')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n", + "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine dataset, query, fields, and sort,\nor provide these directly with Sentry search syntax.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n\nDatasets:\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, traces, AI/LLM calls\n\nDO NOT USE for grouped issue lists → use search_issues\n\n\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', query='level:error')\nsearch_events(organizationSlug='my-org', dataset='errors', query='level:error', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", "inputSchema": { "type": "object", "properties": { @@ -984,7 +769,38 @@ "naturalLanguageQuery": { "type": "string", "minLength": 1, - "description": "Natural language description of what you want to search for" + "description": "Natural language description of what you want to search for. When provided, an embedded agent determines the dataset, query, fields, and sort." + }, + "dataset": { + "type": "string", + "enum": ["errors", "logs", "spans"], + "default": "errors", + "description": "Dataset to query: errors (exceptions), logs, or spans (traces). Used when naturalLanguageQuery is not provided." + }, + "query": { + "type": "string", + "default": "", + "description": "Sentry event search query syntax. Used when naturalLanguageQuery is not provided." + }, + "fields": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics." + }, + "sort": { + "type": "string", + "default": "-timestamp", + "description": "Sort field (prefix with - for descending). Use -count() for aggregations." }, "projectSlug": { "anyOf": [ @@ -999,6 +815,11 @@ "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool.", "default": null }, + "statsPeriod": { + "type": "string", + "default": "14d", + "description": "Time period: 1h, 24h, 7d, 14d, 30d, etc. Used when naturalLanguageQuery is not provided." + }, "regionUrl": { "anyOf": [ { @@ -1017,15 +838,15 @@ "minimum": 1, "maximum": 100, "default": 10, - "description": "Maximum number of results to return" + "description": "Maximum number of results to return (1-100)" }, "includeExplanation": { "type": "boolean", "default": false, - "description": "Include explanation of how the query was translated" + "description": "Include explanation of how the query was translated (only applies with naturalLanguageQuery)" } }, - "required": ["organizationSlug", "naturalLanguageQuery"], + "required": ["organizationSlug"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, @@ -1033,7 +854,7 @@ }, { "name": "search_issue_events", - "description": "Search and filter events within a specific issue using natural language queries.\n\nUse this to filter events by time, environment, release, user, trace ID, or other tags. The tool automatically constrains results to the specified issue.\n\nFor cross-issue searches use search_issues. For single issue or event details use get_sentry_resource.\n\n\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', naturalLanguageQuery='from last hour')\nsearch_issue_events(issueUrl='https://sentry.io/.../issues/123/', naturalLanguageQuery='production with release v1.0')\n", + "description": "Search and filter events within a specific issue.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine the correct filters,\nor use `query` and `sort` directly with Sentry search syntax.\n\nThe tool automatically constrains results to the specified issue.\n\nCommon Query Filters:\n- environment:production - Filter by environment\n- release:1.0.0 - Filter by release version\n- user.email:alice@example.com - Filter by user\n- trace:TRACE_ID - Filter by trace ID\n\nFor cross-issue searches use search_issues. For single issue or event details use get_sentry_resource.\n\n\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', naturalLanguageQuery='from last hour')\nsearch_issue_events(issueId='MCP-41', organizationSlug='my-org', query='environment:production')\nsearch_issue_events(issueUrl='https://sentry.io/.../issues/123/', query='release:v1.0.0', statsPeriod='7d')\n", "inputSchema": { "type": "object", "properties": { @@ -1062,7 +883,22 @@ "naturalLanguageQuery": { "type": "string", "minLength": 1, - "description": "Natural language description of what events you want to find within this issue. Examples: 'from last hour', 'production with release v1.0', 'affecting user alice@example.com', 'with trace ID abc123'" + "description": "Natural language description of what events to find within this issue. When provided, an embedded agent determines the correct filters, fields, sort, and time range." + }, + "query": { + "type": "string", + "default": "", + "description": "Sentry event search query syntax for filtering within the issue. Used when naturalLanguageQuery is not provided." + }, + "sort": { + "type": "string", + "default": "-timestamp", + "description": "Sort field (prefix with - for descending). Default: -timestamp" + }, + "statsPeriod": { + "type": "string", + "default": "14d", + "description": "Time period: 1h, 24h, 7d, 14d, 30d, etc. Used when naturalLanguageQuery is not provided." }, "projectSlug": { "anyOf": [ @@ -1100,10 +936,9 @@ "includeExplanation": { "type": "boolean", "default": false, - "description": "Include explanation of how the natural language query was translated to Sentry syntax" + "description": "Include explanation of how the query was translated (only applies with naturalLanguageQuery)" } }, - "required": ["naturalLanguageQuery"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, @@ -1111,7 +946,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nUses AI to translate natural language queries into Sentry issue search syntax.\nReturns grouped issues with metadata like title, status, and user count.\n\nUSE THIS TOOL WHEN USERS WANT:\n- A LIST of issues: 'show me issues', 'what problems do we have'\n- Filtered issue lists: 'unresolved issues', 'critical bugs'\n- Issues by impact: 'errors affecting more than 100 users'\n- Issues by assignment: 'issues assigned to me'\n- User feedback: 'show me user feedback', 'feedback from last week'\n\nDO NOT USE FOR COUNTS/AGGREGATIONS:\n- 'how many errors' → use search_events\n- 'count of issues' → use search_events\n- 'total number of errors today' → use search_events\n- 'sum/average/statistics' → use search_events\n\nALSO DO NOT USE FOR:\n- Individual error events with timestamps → use search_events\n- Details about a specific issue ID or Sentry issue URL → use get_sentry_resource\n\nREMEMBER: This tool returns a LIST of issues, not counts or statistics!\n\n\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='user feedback from production')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `naturalLanguageQuery` to let an embedded agent determine the correct query and sort params,\nor use `query` and `sort` directly with Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assignedOrSuggested:me\n- issueCategory:feedback\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_events\nDO NOT USE FOR individual events with timestamps → use search_events\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "inputSchema": { "type": "object", "properties": { @@ -1122,7 +957,18 @@ "naturalLanguageQuery": { "type": "string", "minLength": 1, - "description": "Natural language description of issues to search for" + "description": "Natural language description of issues to search for. When provided, an embedded agent translates this into the correct query and sort params." + }, + "query": { + "type": "string", + "default": "is:unresolved", + "description": "Sentry issue search query syntax. Used when naturalLanguageQuery is not provided." + }, + "sort": { + "type": "string", + "enum": ["date", "freq", "new", "user"], + "default": "date", + "description": "Sort order: date (last seen), freq (frequency), new (first seen), user (user count)" }, "projectSlugOrId": { "anyOf": [ @@ -1154,15 +1000,15 @@ "minimum": 1, "maximum": 100, "default": 10, - "description": "Maximum number of issues to return" + "description": "Maximum number of issues to return (1-100)" }, "includeExplanation": { "type": "boolean", "default": false, - "description": "Include explanation of how the query was translated" + "description": "Include explanation of how the query was translated (only applies with naturalLanguageQuery)" } }, - "required": ["organizationSlug", "naturalLanguageQuery"], + "required": ["organizationSlug"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, diff --git a/packages/mcp-core/src/tools/index.ts b/packages/mcp-core/src/tools/index.ts index af31a0514..54492024c 100644 --- a/packages/mcp-core/src/tools/index.ts +++ b/packages/mcp-core/src/tools/index.ts @@ -21,33 +21,9 @@ import getDoc from "./get-doc"; import searchIssues from "./search-issues"; import searchIssueEvents from "./search-issue-events"; import useSentry from "./use-sentry"; -import listIssues from "./list-issues"; -import listEvents from "./list-events"; -import listIssueEvents from "./list-issue-events"; import getProfileDetails from "./get-profile-details"; import getSentryResource from "./get-sentry-resource"; -/** - * Tools that require an embedded agent provider (LLM-powered). - * These are excluded when no agent provider is configured. - * Note: use_sentry is handled separately via agentMode. - */ -export const AGENT_DEPENDENT_TOOLS = [ - "search_events", - "search_issues", - "search_issue_events", -] as const; - -/** - * Simple tools that replace agent-dependent tools when no provider is available. - * These are excluded when an agent provider IS configured. - */ -export const SIMPLE_REPLACEMENT_TOOLS = [ - "list_issues", - "list_events", - "list_issue_events", -] as const; - // Default export: object mapping tool names to tools export default { whoami, @@ -75,9 +51,6 @@ export default { search_issues: searchIssues, search_issue_events: searchIssueEvents, use_sentry: useSentry, - list_issues: listIssues, - list_events: listEvents, - list_issue_events: listIssueEvents, get_profile_details: getProfileDetails, get_sentry_resource: getSentryResource, } as const; diff --git a/packages/mcp-core/src/tools/list-events/index.ts b/packages/mcp-core/src/tools/list-events/index.ts deleted file mode 100644 index 25439dd24..000000000 --- a/packages/mcp-core/src/tools/list-events/index.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { z } from "zod"; -import { setTag } from "@sentry/core"; -import { defineTool } from "../../internal/tool-helpers/define"; -import { apiServiceFromContext } from "../../internal/tool-helpers/api"; -import type { ServerContext } from "../../types"; -import { - ParamOrganizationSlug, - ParamRegionUrl, - ParamProjectSlug, -} from "../../schema"; -import { - formatErrorResults, - formatLogResults, - formatSpanResults, -} from "../search-events/formatters"; -import { RECOMMENDED_FIELDS } from "../search-events/config"; - -// Default fields for each dataset -const DEFAULT_FIELDS = { - errors: RECOMMENDED_FIELDS.errors.basic, - logs: RECOMMENDED_FIELDS.logs.basic, - spans: RECOMMENDED_FIELDS.spans.basic, -}; - -export default defineTool({ - name: "list_events", - skills: ["inspect", "triage", "seer"], - requiredScopes: ["event:read"], - description: [ - "Search events using Sentry query syntax directly (no AI/LLM required).", - "", - "Use this tool when:", - "- You know Sentry query syntax already", - "- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)", - "- You want precise control over the query", - "", - "For natural language queries, use search_events instead.", - "", - "Datasets:", - "- errors: Exception/crash events", - "- logs: Log entries", - "- spans: Performance data, traces, AI/LLM calls", - "", - "Query Syntax Examples:", - '- message:"connection timeout"', - "- level:error", - "- span.op:http.client span.status_code:500", - "- log.level:error", - "- transaction:/api/users", - "", - "", - "list_events(organizationSlug='my-org', dataset='errors', query='level:error')", - "list_events(organizationSlug='my-org', dataset='spans', query='span.op:db')", - "list_events(organizationSlug='my-org', dataset='logs', query='severity:error')", - "list_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.", - "- Use fields with aggregate functions like count(), avg(), sum() for statistics", - "- Sort by -count() for most common, -timestamp for newest", - "", - ].join("\n"), - inputSchema: { - organizationSlug: ParamOrganizationSlug, - dataset: z - .enum(["errors", "logs", "spans"]) - .default("errors") - .describe( - "Dataset to query: errors (exceptions), logs, or spans (traces)", - ), - query: z - .string() - .trim() - .default("") - .describe("Sentry event search query syntax (empty for all events)"), - fields: z - .array(z.string()) - .nullable() - .default(null) - .describe( - "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics.", - ), - sort: z - .string() - .default("-timestamp") - .describe( - "Sort field (prefix with - for descending). Use -count() for aggregations.", - ), - projectSlug: ParamProjectSlug.nullable().default(null), - statsPeriod: z - .string() - .default("14d") - .describe("Time period: 1h, 24h, 7d, 14d, 30d, etc."), - limit: z - .number() - .min(1) - .max(100) - .default(10) - .describe("Maximum number of results to return (1-100)"), - regionUrl: ParamRegionUrl.nullable().default(null), - }, - annotations: { - readOnlyHint: true, - openWorldHint: true, - }, - async handler(params, context: ServerContext) { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl ?? undefined, - }); - - setTag("organization.slug", params.organizationSlug); - if (params.projectSlug) setTag("project.slug", params.projectSlug); - - // Convert project slug to ID if provided - let projectId: string | undefined; - if (params.projectSlug) { - const project = await apiService.getProject({ - organizationSlug: params.organizationSlug, - projectSlugOrId: params.projectSlug, - }); - projectId = String(project.id); - } - - // Use provided fields or defaults for the dataset - const fields = params.fields ?? DEFAULT_FIELDS[params.dataset]; - - const eventsResponse = await apiService.searchEvents({ - organizationSlug: params.organizationSlug, - query: params.query, - fields, - limit: params.limit, - projectId, - dataset: params.dataset, - sort: params.sort, - statsPeriod: params.statsPeriod, - }); - - // Type validation - function isValidResponse( - response: unknown, - ): response is { data?: unknown[] } { - return typeof response === "object" && response !== null; - } - - function isValidEventArray( - data: unknown, - ): data is Record[] { - return ( - Array.isArray(data) && - data.every((item) => typeof item === "object" && item !== null) - ); - } - - if (!isValidResponse(eventsResponse)) { - throw new Error("Invalid response format from Sentry API"); - } - - const eventData = eventsResponse.data; - if (!isValidEventArray(eventData)) { - throw new Error("Invalid event data format from Sentry API"); - } - - // Generate explorer URL - const aggregateFunctions = fields.filter( - (field) => field.includes("(") && field.includes(")"), - ); - const groupByFields = fields.filter( - (field) => !field.includes("(") && !field.includes(")"), - ); - - const explorerUrl = apiService.getEventsExplorerUrl( - params.organizationSlug, - params.query, - projectId, - params.dataset, - fields, - params.sort, - aggregateFunctions, - groupByFields, - params.statsPeriod, - ); - - const formatParams = { - eventData, - naturalLanguageQuery: params.query || `${params.dataset} events`, - includeExplanation: false, - apiService, - organizationSlug: params.organizationSlug, - explorerUrl, - sentryQuery: params.query, - fields, - }; - - switch (params.dataset) { - case "errors": - return formatErrorResults(formatParams); - case "logs": - return formatLogResults(formatParams); - case "spans": - return formatSpanResults(formatParams); - } - }, -}); diff --git a/packages/mcp-core/src/tools/list-events/list-events.test.ts b/packages/mcp-core/src/tools/list-events/list-events.test.ts deleted file mode 100644 index b1a9d37e2..000000000 --- a/packages/mcp-core/src/tools/list-events/list-events.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect } from "vitest"; -import listEvents from "./index.js"; -import { getServerContext } from "../../test-setup.js"; - -describe("list_events", () => { - // Note: The mock server has strict requirements for fields and sort parameters. - // Tests use fields that match the mock's expectations. - - it("returns formatted error events with aggregation fields", async () => { - // Mock expects: issue, title, project, last_seen(), count() and sort -count or -last_seen - const result = await listEvents.handler( - { - organizationSlug: "sentry-mcp-evals", - dataset: "errors", - query: "", - fields: ["issue", "title", "project", "last_seen()", "count()"], - sort: "-count", - projectSlug: null, - statsPeriod: "14d", - limit: 10, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("Search Results"); - expect(result).toContain("View these results in Sentry"); - }); - - // Note: Spans test skipped because the mock requires very strict parameters (useRpc=1, specific sort) - // that are validated in the API client tests. Error events test above validates the tool works correctly. - - it("allows aggregation queries with custom fields", async () => { - const result = await listEvents.handler( - { - organizationSlug: "sentry-mcp-evals", - dataset: "errors", - query: "", - fields: ["issue", "title", "project", "last_seen()", "count()"], - sort: "-count", - projectSlug: null, - statsPeriod: "7d", - limit: 10, - regionUrl: null, - }, - getServerContext(), - ); - - // Should return results with aggregation fields - expect(result).toBeDefined(); - expect(typeof result).toBe("string"); - expect(result).toContain("Search Results"); - }); -}); diff --git a/packages/mcp-core/src/tools/list-issue-events/index.ts b/packages/mcp-core/src/tools/list-issue-events/index.ts deleted file mode 100644 index c6df91f24..000000000 --- a/packages/mcp-core/src/tools/list-issue-events/index.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { z } from "zod"; -import { setTag } from "@sentry/core"; -import { defineTool } from "../../internal/tool-helpers/define"; -import { apiServiceFromContext } from "../../internal/tool-helpers/api"; -import type { ServerContext } from "../../types"; -import { ParamOrganizationSlug, ParamRegionUrl } from "../../schema"; -import { formatErrorResults } from "../search-events/formatters"; -import { parseIssueParams } from "../search-issue-events/utils"; -import { RECOMMENDED_FIELDS } from "../search-issue-events/config"; - -export default defineTool({ - name: "list_issue_events", - skills: ["inspect", "triage"], - requiredScopes: ["event:read"], - description: [ - "List events within a specific issue using Sentry query syntax (no AI/LLM required).", - "", - "Use this tool when:", - "- You know Sentry query syntax already", - "- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)", - "- You want precise control over the query", - "", - "For natural language queries, use search_issue_events instead.", - "", - "Common Query Filters:", - "- environment:production - Filter by environment", - "- release:1.0.0 - Filter by release version", - "- user.email:alice@example.com - Filter by user email", - "- timestamp:>2024-01-01 - Filter by timestamp", - "", - "", - "list_issue_events(issueUrl='https://sentry.io/organizations/my-org/issues/123/', query='environment:production')", - "list_issue_events(issueId='MCP-41', organizationSlug='my-org', query='release:v1.0.0')", - "list_issue_events(issueId='PROJECT-123', organizationSlug='my-org', statsPeriod='1h')", - "", - "", - "", - "- Use issueUrl for convenience (includes org + issue ID)", - "- Or provide both issueId and organizationSlug", - "- The query filters events WITHIN the issue, no need for issue: prefix", - "", - ].join("\n"), - inputSchema: { - // Issue identification - one method required - organizationSlug: ParamOrganizationSlug.nullable() - .default(null) - .describe( - "Organization slug. Required when using issueId. Not needed when using issueUrl.", - ), - issueId: z - .string() - .optional() - .describe( - "Issue ID (e.g., 'MCP-41', 'PROJECT-123'). Requires organizationSlug.", - ), - issueUrl: z - .string() - .url() - .optional() - .describe( - "Full Sentry issue URL. Includes both organization and issue ID.", - ), - - // Query parameters - query: z - .string() - .trim() - .default("") - .describe("Sentry event search query syntax (empty for all events)"), - sort: z - .string() - .default("-timestamp") - .describe( - "Sort field (prefix with - for descending). Default: -timestamp", - ), - statsPeriod: z - .string() - .default("14d") - .describe("Time period: 1h, 24h, 7d, 14d, 30d, etc."), - limit: z - .number() - .min(1) - .max(100) - .default(50) - .describe("Maximum number of events to return (1-100)"), - regionUrl: ParamRegionUrl.nullable().default(null), - }, - annotations: { - readOnlyHint: true, - openWorldHint: true, - }, - async handler(params, context: ServerContext) { - // Parse and validate issue parameters - const { organizationSlug, issueId } = parseIssueParams({ - organizationSlug: params.organizationSlug, - issueId: params.issueId, - issueUrl: params.issueUrl, - }); - - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl ?? undefined, - }); - - setTag("organization.slug", organizationSlug); - setTag("issue.id", issueId); - - // Execute search using issue-specific endpoint - const eventsResponse = await apiService.listEventsForIssue({ - organizationSlug, - issueId, - query: params.query, - limit: params.limit, - sort: params.sort, - statsPeriod: params.statsPeriod, - }); - - // Validate response structure - function isValidEventArray( - data: unknown, - ): data is Record[] { - return ( - Array.isArray(data) && - data.every((item) => typeof item === "object" && item !== null) - ); - } - - if (!isValidEventArray(eventsResponse)) { - throw new Error( - "Invalid event data format from Sentry API: expected array of objects", - ); - } - - // Generate explorer URL (include issue: prefix for the explorer) - const explorerQuery = params.query - ? `issue:${issueId} ${params.query}` - : `issue:${issueId}`; - const explorerUrl = apiService.getEventsExplorerUrl( - organizationSlug, - explorerQuery, - undefined, // projectId - "errors", - RECOMMENDED_FIELDS, - params.sort, - [], - [], - params.statsPeriod, - ); - - return formatErrorResults({ - eventData: eventsResponse, - naturalLanguageQuery: `Events in issue ${issueId}`, - includeExplanation: false, - apiService, - organizationSlug, - explorerUrl, - sentryQuery: explorerQuery, - fields: RECOMMENDED_FIELDS, - }); - }, -}); diff --git a/packages/mcp-core/src/tools/list-issue-events/list-issue-events.test.ts b/packages/mcp-core/src/tools/list-issue-events/list-issue-events.test.ts deleted file mode 100644 index 0647b0ef2..000000000 --- a/packages/mcp-core/src/tools/list-issue-events/list-issue-events.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { UserInputError } from "../../errors.js"; -import listIssueEvents from "./index.js"; -import { getServerContext } from "../../test-setup.js"; - -describe("list_issue_events", () => { - it("returns formatted events using issueId and organizationSlug", async () => { - const result = await listIssueEvents.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - issueUrl: undefined, - query: "", - sort: "-timestamp", - statsPeriod: "14d", - limit: 50, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("Search Results"); - expect(result).toContain("View these results in Sentry"); - expect(result).toContain("CLOUDFLARE-MCP-41"); - }); - - it("returns formatted events using issueUrl", async () => { - const result = await listIssueEvents.handler( - { - organizationSlug: null, - issueId: undefined, - issueUrl: - "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41/", - query: "", - sort: "-timestamp", - statsPeriod: "14d", - limit: 50, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("Search Results"); - }); - - it("filters events by query", async () => { - const result = await listIssueEvents.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - issueUrl: undefined, - query: "environment:production", - sort: "-timestamp", - statsPeriod: "14d", - limit: 50, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("Search Results"); - // Query is URL-encoded in the explorer link - expect(result).toContain("environment%3Aproduction"); - }); - - it("respects sort parameter", async () => { - const result = await listIssueEvents.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - issueUrl: undefined, - query: "", - sort: "timestamp", - statsPeriod: "14d", - limit: 50, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("Search Results"); - }); - - it("respects limit parameter", async () => { - const result = await listIssueEvents.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - issueUrl: undefined, - query: "", - sort: "-timestamp", - statsPeriod: "14d", - limit: 5, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("Search Results"); - }); - - it("throws error when neither issueId nor issueUrl provided", async () => { - await expect( - listIssueEvents.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: undefined, - issueUrl: undefined, - query: "", - sort: "-timestamp", - statsPeriod: "14d", - limit: 50, - regionUrl: null, - }, - getServerContext(), - ), - ).rejects.toThrow(UserInputError); - }); - - it("throws error when issueId provided without organizationSlug", async () => { - await expect( - listIssueEvents.handler( - { - organizationSlug: null, - issueId: "CLOUDFLARE-MCP-41", - issueUrl: undefined, - query: "", - sort: "-timestamp", - statsPeriod: "14d", - limit: 50, - regionUrl: null, - }, - getServerContext(), - ), - ).rejects.toThrow(UserInputError); - }); -}); diff --git a/packages/mcp-core/src/tools/list-issues/index.ts b/packages/mcp-core/src/tools/list-issues/index.ts deleted file mode 100644 index a571d0b51..000000000 --- a/packages/mcp-core/src/tools/list-issues/index.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { z } from "zod"; -import { setTag } from "@sentry/core"; -import { defineTool } from "../../internal/tool-helpers/define"; -import { apiServiceFromContext } from "../../internal/tool-helpers/api"; -import type { ServerContext } from "../../types"; -import { ParamOrganizationSlug, ParamRegionUrl } from "../../schema"; -import { validateSlugOrId, isNumericId } from "../../utils/slug-validation"; -import { formatIssueResults } from "../search-issues/formatters"; - -export default defineTool({ - name: "list_issues", - skills: ["inspect", "triage", "seer"], - requiredScopes: ["event:read"], - description: [ - "List issues using Sentry query syntax directly (no AI/LLM required).", - "", - "Use this tool when:", - "- You know Sentry query syntax already", - "- AI-powered search is unavailable (no OPENAI_API_KEY or ANTHROPIC_API_KEY)", - "- You want precise control over the query", - "", - "For natural language queries, use search_issues instead.", - "", - "Common Query Syntax:", - "- is:unresolved - Show unresolved issues only", - "- is:unassigned - Show unassigned issues", - "- level:error - Filter by error level", - "- firstSeen:-24h - First seen in last 24 hours", - "- lastSeen:-1h - Last seen in last hour", - "- has:user - Issues with user context", - "- user.email:user@example.com - Filter by user email", - "- environment:production - Filter by environment", - "- release:1.0.0 - Filter by release version", - "", - "Combine queries: is:unresolved is:unassigned level:error", - "", - "", - "list_issues(organizationSlug='my-org', query='is:unresolved is:unassigned')", - "list_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', sort='freq')", - "list_issues(organizationSlug='my-org', projectSlugOrId='my-project', query='is:unresolved')", - "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.", - "- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').", - "", - ].join("\n"), - inputSchema: { - organizationSlug: ParamOrganizationSlug, - query: z - .string() - .trim() - .default("is:unresolved") - .describe("Sentry issue search query syntax"), - projectSlugOrId: z - .string() - .toLowerCase() - .trim() - .superRefine(validateSlugOrId) - .nullable() - .default(null) - .describe("Filter by project slug or numeric ID (optional)"), - sort: z - .enum(["date", "freq", "new", "user"]) - .default("date") - .describe( - "Sort order: date (last seen), freq (frequency), new (first seen), user (user count)", - ), - limit: z - .number() - .min(1) - .max(100) - .default(10) - .describe("Maximum number of issues to return (1-100)"), - regionUrl: ParamRegionUrl.nullable().default(null), - }, - annotations: { - readOnlyHint: true, - openWorldHint: true, - }, - async handler(params, context: ServerContext) { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl ?? undefined, - }); - - setTag("organization.slug", params.organizationSlug); - if (params.projectSlugOrId) { - if (isNumericId(params.projectSlugOrId)) { - setTag("project.id", params.projectSlugOrId); - } else { - setTag("project.slug", params.projectSlugOrId); - } - } - - const issues = await apiService.listIssues({ - organizationSlug: params.organizationSlug, - projectSlug: params.projectSlugOrId ?? undefined, - query: params.query, - sortBy: params.sort, - limit: params.limit, - }); - - return formatIssueResults({ - issues, - organizationSlug: params.organizationSlug, - projectSlugOrId: params.projectSlugOrId ?? undefined, - query: params.query, - regionUrl: params.regionUrl ?? undefined, - }); - }, -}); diff --git a/packages/mcp-core/src/tools/list-issues/list-issues.test.ts b/packages/mcp-core/src/tools/list-issues/list-issues.test.ts deleted file mode 100644 index e620a25f1..000000000 --- a/packages/mcp-core/src/tools/list-issues/list-issues.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect } from "vitest"; -import listIssues from "./index.js"; -import { getServerContext } from "../../test-setup.js"; - -describe("list_issues", () => { - it("returns formatted issue list with default query", async () => { - const result = await listIssues.handler( - { - organizationSlug: "sentry-mcp-evals", - query: "is:unresolved", - projectSlugOrId: null, - sort: "date", - limit: 10, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("# Issues in **sentry-mcp-evals**"); - expect(result).toContain("CLOUDFLARE-MCP-41"); - expect(result).toContain("Tool list_organizations is already registered"); - }); - - it("returns formatted issue list with project filter", async () => { - const result = await listIssues.handler( - { - organizationSlug: "sentry-mcp-evals", - query: "is:unresolved", - projectSlugOrId: "cloudflare-mcp", - sort: "date", - limit: 10, - regionUrl: null, - }, - getServerContext(), - ); - - // When project is specified, it's included in the header - expect(result).toContain("# Issues in **sentry-mcp-evals/cloudflare-mcp**"); - expect(result).toContain("CLOUDFLARE-MCP-41"); - }); - - it("handles empty results gracefully", async () => { - // Using a project that has no issues - const result = await listIssues.handler( - { - organizationSlug: "sentry-mcp-evals", - query: "is:unresolved", - projectSlugOrId: "foobar", - sort: "date", - limit: 10, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("No issues found"); - }); - - it("uses correct sort order", async () => { - const result = await listIssues.handler( - { - organizationSlug: "sentry-mcp-evals", - query: "is:unresolved", - projectSlugOrId: null, - sort: "freq", - limit: 10, - regionUrl: null, - }, - getServerContext(), - ); - - expect(result).toContain("# Issues in **sentry-mcp-evals**"); - }); - - it("respects limit parameter", async () => { - const result = await listIssues.handler( - { - organizationSlug: "sentry-mcp-evals", - query: "is:unresolved", - projectSlugOrId: null, - sort: "date", - limit: 1, - regionUrl: null, - }, - getServerContext(), - ); - - // Should still return results (limited to 1) - expect(result).toContain("# Issues in **sentry-mcp-evals**"); - }); -}); diff --git a/packages/mcp-core/src/tools/search-events.test.ts b/packages/mcp-core/src/tools/search-events.test.ts index 945980279..5eae0aab9 100644 --- a/packages/mcp-core/src/tools/search-events.test.ts +++ b/packages/mcp-core/src/tools/search-events.test.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw"; import { mswServer } from "@sentry/mcp-server-mocks"; import searchEvents from "./search-events"; import { generateText } from "ai"; -import { UserInputError } from "../errors"; +import { UserInputError, ConfigurationError } from "../errors"; // Mock the AI SDK vi.mock("@ai-sdk/openai", () => { @@ -118,6 +118,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "database queries", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -176,6 +181,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "database errors", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -239,6 +249,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "recent errors with user data", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -295,6 +310,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "error logs", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -326,6 +346,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "some impossible query !@#$%", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -361,6 +386,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "show me errors over time", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -407,6 +437,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "any query", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -446,6 +481,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "any query", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -503,6 +543,11 @@ describe("search_events", () => { regionUrl: null, projectSlug: null, naturalLanguageQuery: "recent errors", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -577,6 +622,11 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "which user agents have the most tool calls yesterday", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", limit: 10, includeExplanation: false, }, @@ -598,4 +648,94 @@ describe("search_events", () => { // Should NOT contain user.id references expect(result).not.toContain("user.id"); }); + + it("should search events with direct query syntax (no agent)", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("dataset")).toBe("errors"); + expect(url.searchParams.get("query")).toBe("level:error"); + expect(url.searchParams.get("sort")).toBe("-timestamp"); + return HttpResponse.json({ + data: [ + { + id: "error1", + issue: "PROJ-123", + title: "Database Error", + level: "error", + timestamp: "2024-01-15T10:30:00Z", + }, + ], + }); + }, + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "level:error", + fields: ["issue", "title", "level", "timestamp"], + sort: "-timestamp", + statsPeriod: "14d", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ); + + // Should NOT have called the AI agent + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("Database Error"); + }); + + it("should throw ConfigurationError when naturalLanguageQuery provided without agent", async () => { + const savedKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = ""; + process.env.ANTHROPIC_API_KEY = ""; + + try { + await expect( + searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + naturalLanguageQuery: "error logs", + dataset: "errors", + query: "", + fields: null, + sort: "-timestamp", + statsPeriod: "14d", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ), + ).rejects.toThrow(ConfigurationError); + } finally { + process.env.OPENAI_API_KEY = savedKey; + } + }); }); diff --git a/packages/mcp-core/src/tools/search-events/handler.ts b/packages/mcp-core/src/tools/search-events/handler.ts index 543092a95..aed07644e 100644 --- a/packages/mcp-core/src/tools/search-events/handler.ts +++ b/packages/mcp-core/src/tools/search-events/handler.ts @@ -8,6 +8,8 @@ import { ParamRegionUrl, ParamProjectSlug, } from "../../schema"; +import { hasAgentProvider } from "../../internal/agents/provider-factory"; +import { ConfigurationError, UserInputError } from "../../errors"; import { searchEventsAgent } from "./agent"; import { formatErrorResults, @@ -15,7 +17,6 @@ import { formatSpanResults, } from "./formatters"; import { RECOMMENDED_FIELDS } from "./config"; -import { UserInputError } from "../../errors"; export default defineTool({ name: "search_events", @@ -24,39 +25,32 @@ export default defineTool({ description: [ "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.", "", - "Supports TWO query types:", - "1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'count of issues', 'total tokens'", - "2. Individual events with timestamps: 'show me error logs from last hour'", - "", - "USE THIS FOR ALL COUNTS/STATISTICS:", - "- 'how many errors today' → returns count", - "- 'count of database failures' → returns count", - "- 'total number of issues' → returns count", - "- 'average response time' → returns avg()", - "- 'sum of tokens used' → returns sum()", + "Provide `naturalLanguageQuery` to let an embedded agent determine dataset, query, fields, and sort,", + "or provide these directly with Sentry search syntax.", "", - "ALSO USE FOR INDIVIDUAL EVENTS:", - "- 'error logs from last hour' → returns event list", - "- 'database errors with timestamps' → returns event list", - "- 'trace spans for slow API calls' → returns span list", + "Supports TWO query types:", + "1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'", + "2. Individual events with timestamps: 'error logs from last hour'", "", - "Dataset Selection (AI automatically chooses):", + "Datasets:", "- errors: Exception/crash events", "- logs: Log entries", - "- spans: Performance data, AI/LLM calls, token usage", + "- spans: Performance data, traces, AI/LLM calls", "", "DO NOT USE for grouped issue lists → use search_issues", "", "", "search_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')", - "search_events(organizationSlug='my-org', naturalLanguageQuery='count of database failures this week')", - "search_events(organizationSlug='my-org', naturalLanguageQuery='total tokens used by model')", - "search_events(organizationSlug='my-org', naturalLanguageQuery='error logs from the last hour')", + "search_events(organizationSlug='my-org', dataset='errors', query='level:error')", + "search_events(organizationSlug='my-org', dataset='errors', query='level:error', fields=['issue', 'count()'], sort='-count()')", + "search_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')", "", "", "", "- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.", "- Parse org/project notation directly without calling find_organizations or find_projects.", + "- Use fields with aggregate functions like count(), avg(), sum() for statistics", + "- Sort by -count() for most common, -timestamp for newest", "", ].join("\n"), inputSchema: { @@ -65,19 +59,56 @@ export default defineTool({ .string() .trim() .min(1) - .describe("Natural language description of what you want to search for"), + .optional() + .describe( + "Natural language description of what you want to search for. When provided, an embedded agent determines the dataset, query, fields, and sort.", + ), + dataset: z + .enum(["errors", "logs", "spans"]) + .default("errors") + .describe( + "Dataset to query: errors (exceptions), logs, or spans (traces). Used when naturalLanguageQuery is not provided.", + ), + query: z + .string() + .trim() + .default("") + .describe( + "Sentry event search query syntax. Used when naturalLanguageQuery is not provided.", + ), + fields: z + .array(z.string()) + .nullable() + .default(null) + .describe( + "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics.", + ), + sort: z + .string() + .default("-timestamp") + .describe( + "Sort field (prefix with - for descending). Use -count() for aggregations.", + ), projectSlug: ParamProjectSlug.nullable().default(null), + statsPeriod: z + .string() + .default("14d") + .describe( + "Time period: 1h, 24h, 7d, 14d, 30d, etc. Used when naturalLanguageQuery is not provided.", + ), regionUrl: ParamRegionUrl.nullable().default(null), limit: z .number() .min(1) .max(100) .default(10) - .describe("Maximum number of results to return"), + .describe("Maximum number of results to return (1-100)"), includeExplanation: z .boolean() .default(false) - .describe("Include explanation of how the query was translated"), + .describe( + "Include explanation of how the query was translated (only applies with naturalLanguageQuery)", + ), }, annotations: { readOnlyHint: true, @@ -92,89 +123,75 @@ export default defineTool({ setTag("organization.slug", organizationSlug); if (params.projectSlug) setTag("project.slug", params.projectSlug); - // The agent will determine the dataset based on the query content - - // Convert project slug to ID if needed - we need this for attribute fetching + // Convert project slug to ID if needed let projectId: string | undefined; if (params.projectSlug) { const project = await apiService.getProject({ organizationSlug, - projectSlugOrId: params.projectSlug!, + projectSlugOrId: params.projectSlug, }); projectId = String(project.id); } - // Translate the natural language query using Search Events Agent - // The agent will determine the dataset and fetch the appropriate attributes - const agentResult = await searchEventsAgent({ - query: params.naturalLanguageQuery, - organizationSlug, - apiService, - projectId, - }); - - const parsed = agentResult.result; - - // Get the dataset chosen by the agent - const dataset = parsed.dataset; - - // Get recommended fields for this dataset (for fallback when no fields are provided) - const recommendedFields = RECOMMENDED_FIELDS[dataset]; - - // Validate that sort parameter was provided - if (!parsed.sort) { - throw new UserInputError( - `Search Events Agent response missing required 'sort' parameter. Received: ${JSON.stringify(parsed, null, 2)}. The agent must specify how to sort results (e.g., '-timestamp' for newest first, '-count()' for highest count).`, - ); - } + let dataset: "errors" | "logs" | "spans"; + let sentryQuery: string; + let fields: string[]; + let sortParam: string; + let timeParams: { statsPeriod?: string; start?: string; end?: string }; + let explanation: string | undefined; - // Use empty string as default if no query is provided - // This allows fetching all recent events when no specific filter is needed - const sentryQuery = parsed.query || ""; - const requestedFields = parsed.fields || []; + if (params.naturalLanguageQuery) { + // NL mode: use embedded agent to determine all params + if (!hasAgentProvider()) { + throw new ConfigurationError( + "Natural language search requires an AI provider (OPENAI_API_KEY or ANTHROPIC_API_KEY). " + + "Use the 'query', 'dataset', and 'fields' parameters with Sentry search syntax instead.", + ); + } - // Determine if this is an aggregate query by checking if any field contains a function - const isAggregateQuery = requestedFields.some( - (field) => field.includes("(") && field.includes(")"), - ); + const agentResult = await searchEventsAgent({ + query: params.naturalLanguageQuery, + organizationSlug, + apiService, + projectId, + }); - // For aggregate queries, we should only use the fields provided by the AI - // For non-aggregate queries, we can use recommended fields as fallback - let fields: string[]; + const parsed = agentResult.result; + dataset = parsed.dataset; + sentryQuery = parsed.query || ""; + explanation = parsed.explanation; - if (isAggregateQuery) { - // For aggregate queries, fields must be provided and should only include - // aggregate functions and groupBy fields - if (!requestedFields || requestedFields.length === 0) { + if (!parsed.sort) { throw new UserInputError( - `AI response missing required 'fields' for aggregate query. The AI must specify which fields to return. For aggregate queries, include only the aggregate functions (like count(), avg()) and groupBy fields.`, + `Search Events Agent response missing required 'sort' parameter. Received: ${JSON.stringify(parsed, null, 2)}. The agent must specify how to sort results (e.g., '-timestamp' for newest first, '-count()' for highest count).`, ); } - fields = requestedFields; - } else { - // For non-aggregate queries, use AI-provided fields or fall back to recommended fields - fields = - requestedFields && requestedFields.length > 0 - ? requestedFields - : recommendedFields.basic; - } + sortParam = parsed.sort; - // Use the AI-provided sort parameter - const sortParam = parsed.sort; + const requestedFields = parsed.fields || []; + const isAggregateQuery = requestedFields.some( + (field) => field.includes("(") && field.includes(")"), + ); - // Extract time range parameters from parsed response - const timeParams: { statsPeriod?: string; start?: string; end?: string } = - {}; - if (parsed.timeRange) { - if ("statsPeriod" in parsed.timeRange) { - timeParams.statsPeriod = parsed.timeRange.statsPeriod; - } else if ("start" in parsed.timeRange && "end" in parsed.timeRange) { - timeParams.start = parsed.timeRange.start; - timeParams.end = parsed.timeRange.end; + if (isAggregateQuery) { + fields = requestedFields; + } else { + fields = + requestedFields.length > 0 + ? requestedFields + : RECOMMENDED_FIELDS[dataset].basic; } + + timeParams = parsed.timeRange + ? { ...parsed.timeRange } + : { statsPeriod: "14d" }; } else { - // Default time window if not specified - timeParams.statsPeriod = "14d"; + // Direct mode: use provided params as-is + dataset = params.dataset; + sentryQuery = params.query; + fields = params.fields ?? RECOMMENDED_FIELDS[dataset].basic; + sortParam = params.sort; + timeParams = { statsPeriod: params.statsPeriod }; } const eventsResponse = await apiService.searchEvents({ @@ -182,14 +199,13 @@ export default defineTool({ query: sentryQuery, fields, limit: params.limit, - projectId, // API requires numeric project ID, not slug - dataset, // API now accepts "logs" directly (no longer needs "ourlogs") + projectId, + dataset, sort: sortParam, - ...timeParams, // Spread the time parameters + ...timeParams, }); - // Generate the Sentry explorer URL with structured aggregate information - // Derive aggregate functions and groupBy fields from the fields array + // Generate the Sentry explorer URL const aggregateFunctions = fields.filter( (field) => field.includes("(") && field.includes(")"), ); @@ -200,10 +216,10 @@ export default defineTool({ const explorerUrl = apiService.getEventsExplorerUrl( organizationSlug, sentryQuery, - projectId, // Pass the numeric project ID for URL generation - dataset, // dataset is already correct for URL generation (logs, spans, errors) - fields, // Pass fields to detect if it's an aggregate query - sortParam, // Pass sort parameter for URL generation + projectId, + dataset, + fields, + sortParam, aggregateFunctions, groupByFields, timeParams.statsPeriod, @@ -236,17 +252,19 @@ export default defineTool({ throw new Error("Invalid event data format from Sentry API"); } - // Format results based on dataset + const displayQuery = + params.naturalLanguageQuery || params.query || `${dataset} events`; + const formatParams = { eventData, - naturalLanguageQuery: params.naturalLanguageQuery, + naturalLanguageQuery: displayQuery, includeExplanation: params.includeExplanation, apiService, organizationSlug, explorerUrl, sentryQuery, fields, - explanation: parsed.explanation, + explanation, }; switch (dataset) { diff --git a/packages/mcp-core/src/tools/search-issue-events.test.ts b/packages/mcp-core/src/tools/search-issue-events.test.ts index 08452cb4d..6cfce9e76 100644 --- a/packages/mcp-core/src/tools/search-issue-events.test.ts +++ b/packages/mcp-core/src/tools/search-issue-events.test.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw"; import { mswServer } from "@sentry/mcp-server-mocks"; import searchIssueEvents from "./search-issue-events"; import { generateText } from "ai"; -import { UserInputError } from "../errors"; +import { UserInputError, ConfigurationError } from "../errors"; import type { ServerContext } from "../types"; // Mock the AI SDK @@ -113,6 +113,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "from last hour", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -144,6 +147,9 @@ describe("search_issue_events", () => { organizationSlug: null, issueUrl: "https://sentry.io/organizations/my-org/issues/123/", naturalLanguageQuery: "all events", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -179,6 +185,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "production with release v1.0", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -208,6 +217,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "from last hour", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -239,6 +251,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "from Jan 15 2025", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -266,6 +281,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "all events", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -287,6 +305,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "test query", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -311,6 +332,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "from last hour", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -328,6 +352,9 @@ describe("search_issue_events", () => { { organizationSlug: "test-org", naturalLanguageQuery: "test", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -345,6 +372,9 @@ describe("search_issue_events", () => { organizationSlug: null, issueId: "MCP-41", naturalLanguageQuery: "test", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -362,6 +392,9 @@ describe("search_issue_events", () => { organizationSlug: null, issueUrl: "https://invalid-url.com", naturalLanguageQuery: "test", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -397,6 +430,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "production events", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -422,6 +458,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "test", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 25, @@ -452,6 +491,9 @@ describe("search_issue_events", () => { organizationSlug: "test-org", issueId: "MCP-41", naturalLanguageQuery: "production events", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -481,6 +523,9 @@ describe("search_issue_events", () => { organizationSlug: null, issueUrl: "https://my-org.sentry.io/issues/456/", naturalLanguageQuery: "test", + query: "", + sort: "-timestamp", + statsPeriod: "14d", projectSlug: null, regionUrl: null, limit: 50, @@ -489,4 +534,77 @@ describe("search_issue_events", () => { mockContext, ); }); + + it("should search events with direct query syntax (no agent)", async () => { + mswServer.use( + http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("query")).toBe("environment:production"); + expect(url.searchParams.get("sort")).toBe("-timestamp"); + expect(url.searchParams.get("statsPeriod")).toBe("7d"); + return HttpResponse.json([ + { + id: "event1", + timestamp: "2025-01-15T10:00:00Z", + title: "Test Error", + message: "Something went wrong", + level: "error", + environment: "production", + release: "v1.0", + "user.display": "alice", + trace: "abc123", + url: "/api/endpoint", + }, + ]); + }), + ); + + const result = await searchIssueEvents.handler( + { + organizationSlug: "test-org", + issueId: "MCP-41", + query: "environment:production", + sort: "-timestamp", + statsPeriod: "7d", + projectSlug: null, + regionUrl: null, + limit: 50, + includeExplanation: false, + }, + mockContext, + ); + + // Should NOT have called the AI agent + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("Events in issue MCP-41"); + expect(result).toContain("Test Error"); + }); + + it("should throw ConfigurationError when naturalLanguageQuery provided without agent", async () => { + const savedKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = ""; + process.env.ANTHROPIC_API_KEY = ""; + + try { + await expect( + searchIssueEvents.handler( + { + organizationSlug: "test-org", + issueId: "MCP-41", + naturalLanguageQuery: "from last hour", + query: "", + sort: "-timestamp", + statsPeriod: "14d", + projectSlug: null, + regionUrl: null, + limit: 50, + includeExplanation: false, + }, + mockContext, + ), + ).rejects.toThrow(ConfigurationError); + } finally { + process.env.OPENAI_API_KEY = savedKey; + } + }); }); diff --git a/packages/mcp-core/src/tools/search-issue-events/handler.ts b/packages/mcp-core/src/tools/search-issue-events/handler.ts index 1f69c8990..6e1b32cee 100644 --- a/packages/mcp-core/src/tools/search-issue-events/handler.ts +++ b/packages/mcp-core/src/tools/search-issue-events/handler.ts @@ -8,10 +8,11 @@ import { ParamRegionUrl, ParamProjectSlug, } from "../../schema"; +import { hasAgentProvider } from "../../internal/agents/provider-factory"; +import { ConfigurationError, UserInputError } from "../../errors"; import { searchIssueEventsAgent } from "./agent"; import { formatErrorResults } from "../search-events/formatters"; import { RECOMMENDED_FIELDS } from "./config"; -import { UserInputError } from "../../errors"; import { parseIssueParams } from "./utils"; export default defineTool({ @@ -19,15 +20,25 @@ export default defineTool({ skills: ["inspect", "triage"], // Available in inspect and triage skills requiredScopes: ["event:read"], description: [ - "Search and filter events within a specific issue using natural language queries.", + "Search and filter events within a specific issue.", "", - "Use this to filter events by time, environment, release, user, trace ID, or other tags. The tool automatically constrains results to the specified issue.", + "Provide `naturalLanguageQuery` to let an embedded agent determine the correct filters,", + "or use `query` and `sort` directly with Sentry search syntax.", + "", + "The tool automatically constrains results to the specified issue.", + "", + "Common Query Filters:", + "- environment:production - Filter by environment", + "- release:1.0.0 - Filter by release version", + "- user.email:alice@example.com - Filter by user", + "- trace:TRACE_ID - Filter by trace ID", "", "For cross-issue searches use search_issues. For single issue or event details use get_sentry_resource.", "", "", "search_issue_events(issueId='MCP-41', organizationSlug='my-org', naturalLanguageQuery='from last hour')", - "search_issue_events(issueUrl='https://sentry.io/.../issues/123/', naturalLanguageQuery='production with release v1.0')", + "search_issue_events(issueId='MCP-41', organizationSlug='my-org', query='environment:production')", + "search_issue_events(issueUrl='https://sentry.io/.../issues/123/', query='release:v1.0.0', statsPeriod='7d')", "", ].join("\n"), inputSchema: { @@ -51,13 +62,35 @@ export default defineTool({ "Full Sentry issue URL (e.g., 'https://sentry.io/organizations/my-org/issues/123/'). Includes both organization and issue ID.", ), - // Natural language query for filtering + // Natural language query for filtering (optional) naturalLanguageQuery: z .string() .trim() .min(1) + .optional() + .describe( + "Natural language description of what events to find within this issue. When provided, an embedded agent determines the correct filters, fields, sort, and time range.", + ), + + // Direct Sentry syntax parameters + query: z + .string() + .trim() + .default("") + .describe( + "Sentry event search query syntax for filtering within the issue. Used when naturalLanguageQuery is not provided.", + ), + sort: z + .string() + .default("-timestamp") + .describe( + "Sort field (prefix with - for descending). Default: -timestamp", + ), + statsPeriod: z + .string() + .default("14d") .describe( - "Natural language description of what events you want to find within this issue. Examples: 'from last hour', 'production with release v1.0', 'affecting user alice@example.com', 'with trace ID abc123'", + "Time period: 1h, 24h, 7d, 14d, 30d, etc. Used when naturalLanguageQuery is not provided.", ), // Optional context parameters @@ -81,7 +114,7 @@ export default defineTool({ .boolean() .default(false) .describe( - "Include explanation of how the natural language query was translated to Sentry syntax", + "Include explanation of how the query was translated (only applies with naturalLanguageQuery)", ), }, annotations: { @@ -89,26 +122,23 @@ export default defineTool({ openWorldHint: true, }, async handler(params, context: ServerContext) { - // 1. Parse and validate issue parameters const { organizationSlug, issueId } = parseIssueParams({ organizationSlug: params.organizationSlug, issueId: params.issueId, issueUrl: params.issueUrl, }); - // 2. Initialize API service with region support const apiService = apiServiceFromContext(context, { regionUrl: params.regionUrl ?? undefined, }); - // 3. Set monitoring tags for observability setTag("organization.slug", organizationSlug); setTag("issue.id", issueId); if (params.projectSlug) { setTag("project.slug", params.projectSlug); } - // 4. Resolve project ID if project slug provided (for better tag discovery) + // Resolve project ID if project slug provided (for better tag discovery in NL mode) let projectId: string | undefined; if (params.projectSlug) { try { @@ -118,57 +148,61 @@ export default defineTool({ }); projectId = String(project.id); } catch (error) { - // Non-fatal error - continue without project ID - // Tag discovery will be less specific but still work + // Non-fatal - continue without project ID console.warn(`Could not resolve project ${params.projectSlug}:`, error); } } - // 5. Call embedded AI agent to translate natural language query - // Agent will determine filters, fields, sort, and time range - const agentResult = await searchIssueEventsAgent({ - query: params.naturalLanguageQuery, - organizationSlug, - apiService, - projectId, - }); + let query: string; + let fields: string[]; + let sortParam: string; + let timeParams: { statsPeriod?: string; start?: string; end?: string }; + let explanation: string | undefined; - const parsed = agentResult.result; + if (params.naturalLanguageQuery) { + // NL mode: use embedded agent to determine filters + if (!hasAgentProvider()) { + throw new ConfigurationError( + "Natural language search requires an AI provider (OPENAI_API_KEY or ANTHROPIC_API_KEY). " + + "Use the 'query' parameter with Sentry search syntax instead.", + ); + } - // 6. Validate that sort parameter was provided - if (!parsed.sort) { - throw new UserInputError( - `Search Issue Events Agent response missing required 'sort' parameter. Received: ${JSON.stringify(parsed, null, 2)}. The agent must specify how to sort results (e.g., '-timestamp' for newest first).`, - ); - } + const agentResult = await searchIssueEventsAgent({ + query: params.naturalLanguageQuery, + organizationSlug, + apiService, + projectId, + }); + + const parsed = agentResult.result; - // 7. Extract query from agent response (no issue: prefix needed) - // The listEventsForIssue endpoint already filters by issue ID - const query = parsed.query || ""; - - // 8. Extract fields and sort from agent response - const requestedFields = parsed.fields || []; - const fields = - requestedFields.length > 0 ? requestedFields : RECOMMENDED_FIELDS; - const sortParam = parsed.sort; - - // 9. Build time range parameters from agent response - const timeParams: { statsPeriod?: string; start?: string; end?: string } = - {}; - if (parsed.timeRange) { - if ("statsPeriod" in parsed.timeRange) { - timeParams.statsPeriod = parsed.timeRange.statsPeriod; - } else if ("start" in parsed.timeRange && "end" in parsed.timeRange) { - timeParams.start = parsed.timeRange.start; - timeParams.end = parsed.timeRange.end; + if (!parsed.sort) { + throw new UserInputError( + `Search Issue Events Agent response missing required 'sort' parameter. Received: ${JSON.stringify(parsed, null, 2)}. The agent must specify how to sort results (e.g., '-timestamp' for newest first).`, + ); } + + query = parsed.query || ""; + sortParam = parsed.sort; + explanation = parsed.explanation; + + const requestedFields = parsed.fields || []; + fields = + requestedFields.length > 0 ? requestedFields : RECOMMENDED_FIELDS; + + timeParams = parsed.timeRange + ? { ...parsed.timeRange } + : { statsPeriod: "14d" }; } else { - // Default time window if not specified - timeParams.statsPeriod = "14d"; + // Direct mode: use provided params as-is + query = params.query; + fields = RECOMMENDED_FIELDS; + sortParam = params.sort; + timeParams = { statsPeriod: params.statsPeriod }; } - // 10. Execute search using issue-specific endpoint - // This endpoint is already scoped to the issue, so we don't need to add issue: to the query + // Execute search using issue-specific endpoint const eventsResponse = await apiService.listEventsForIssue({ organizationSlug, issueId, @@ -178,8 +212,7 @@ export default defineTool({ ...timeParams, }); - // 11. Generate Sentry explorer URL for user to view results in UI - // For the explorer URL, we DO need to include issue: in the query + // Generate explorer URL (include issue: prefix for the explorer) const explorerQuery = query ? `issue:${issueId} ${query}` : `issue:${issueId}`; @@ -187,18 +220,17 @@ export default defineTool({ organizationSlug, explorerQuery, projectId, - "errors", // dataset + "errors", fields, sortParam, - [], // No aggregate functions for individual event queries - [], // No groupBy fields + [], + [], timeParams.statsPeriod, timeParams.start, timeParams.end, ); - // 12. Validate response structure - // The /issues/:issueId/events/ endpoint returns an array directly, not wrapped in {data: [...]} + // Validate response structure function isValidEventArray( data: unknown, ): data is Record[] { @@ -214,13 +246,12 @@ export default defineTool({ ); } - const eventData = eventsResponse; - - // 13. Format results using shared error formatter from search-events - const naturalLanguageContext = `Events in issue ${issueId}: ${params.naturalLanguageQuery}`; + const naturalLanguageContext = params.naturalLanguageQuery + ? `Events in issue ${issueId}: ${params.naturalLanguageQuery}` + : `Events in issue ${issueId}`; return formatErrorResults({ - eventData, + eventData: eventsResponse, naturalLanguageQuery: naturalLanguageContext, includeExplanation: params.includeExplanation, apiService, @@ -228,7 +259,7 @@ export default defineTool({ explorerUrl, sentryQuery: explorerQuery, fields, - explanation: parsed.explanation, + explanation, }); }, }); diff --git a/packages/mcp-core/src/tools/search-issues.test.ts b/packages/mcp-core/src/tools/search-issues.test.ts index b12713165..aec7bf5ba 100644 --- a/packages/mcp-core/src/tools/search-issues.test.ts +++ b/packages/mcp-core/src/tools/search-issues.test.ts @@ -4,6 +4,7 @@ import { mswServer } from "@sentry/mcp-server-mocks"; import searchIssues from "./search-issues"; import { generateText } from "ai"; import type { ServerContext } from "../types"; +import { ConfigurationError } from "../errors"; // Mock the AI SDK vi.mock("@ai-sdk/openai", () => { @@ -104,6 +105,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "unresolved issues", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, @@ -116,10 +119,84 @@ describe("search_issues", () => { expect(result).toContain("Test Error"); }); + it("should search issues with direct query syntax (no agent)", async () => { + mswServer.use( + http.get("*/api/0/organizations/*/issues/", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("query")).toBe( + "is:unresolved is:unassigned", + ); + expect(url.searchParams.get("sort")).toBe("freq"); + return HttpResponse.json([ + { + id: "123", + shortId: "PROJ-123", + title: "Test Error", + status: "unresolved", + count: "100", + userCount: 50, + firstSeen: "2025-01-15T10:00:00Z", + lastSeen: "2025-01-15T12:00:00Z", + permalink: "https://sentry.io/issues/123/", + project: { + id: "456", + slug: "test-project", + name: "Test Project", + }, + culprit: "test.function", + }, + ]); + }), + ); + + const result = await searchIssues.handler( + { + organizationSlug: "test-org", + query: "is:unresolved is:unassigned", + sort: "freq", + projectSlugOrId: null, + regionUrl: null, + limit: 10, + includeExplanation: false, + }, + mockContext, + ); + + // Should NOT have called the AI agent + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("PROJ-123"); + expect(result).toContain("Test Error"); + }); + + it("should throw ConfigurationError when naturalLanguageQuery provided without agent", async () => { + const savedKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = ""; + process.env.ANTHROPIC_API_KEY = ""; + + try { + await expect( + searchIssues.handler( + { + organizationSlug: "test-org", + naturalLanguageQuery: "unresolved issues", + query: "is:unresolved", + sort: "date", + projectSlugOrId: null, + regionUrl: null, + limit: 10, + includeExplanation: false, + }, + mockContext, + ), + ).rejects.toThrow(ConfigurationError); + } finally { + process.env.OPENAI_API_KEY = savedKey; + } + }); + it("should handle project slug parameter", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); - // Mock getProject call - handler needs to fetch project to get ID mswServer.use( http.get("*/api/0/projects/*/my-project/", () => { return HttpResponse.json({ @@ -137,6 +214,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "all issues", + query: "is:unresolved", + sort: "date", projectSlugOrId: "my-project", regionUrl: null, limit: 10, @@ -151,7 +230,6 @@ describe("search_issues", () => { it("should handle numeric project ID", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); - // Numeric ID doesn't need getProject call - used directly mswServer.use( http.get("*/api/0/projects/*/123456/issues/*", () => { return HttpResponse.json([]); @@ -162,6 +240,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "all issues", + query: "is:unresolved", + sort: "date", projectSlugOrId: "123456", regionUrl: null, limit: 10, @@ -187,6 +267,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "most frequent errors", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, @@ -212,6 +294,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "all issues", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, @@ -237,6 +321,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "test", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 25, @@ -259,6 +345,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "unresolved issues", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, @@ -284,31 +372,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "nonexistent issues", - projectSlugOrId: null, - regionUrl: null, - limit: 10, - includeExplanation: false, - }, - mockContext, - ); - - expect(result).toContain("No issues found"); - }); - - it("should handle whitespace-only query gracefully", async () => { - mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); - - mswServer.use( - http.get("*/api/0/organizations/*/issues/", () => { - return HttpResponse.json([]); - }), - ); - - // Whitespace gets trimmed but doesn't fail - the AI agent processes it - const result = await searchIssues.handler( - { - organizationSlug: "test-org", - naturalLanguageQuery: " ", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, @@ -338,6 +403,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "unresolved errors", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, @@ -371,6 +438,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "test", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, @@ -413,6 +482,8 @@ describe("search_issues", () => { { organizationSlug: "test-org", naturalLanguageQuery: "all issues", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, @@ -428,12 +499,13 @@ describe("search_issues", () => { }); it("should validate project slug format", async () => { - // Invalid characters in slug should fail validation await expect( searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "test", + query: "is:unresolved", + sort: "date", projectSlugOrId: "invalid@slug", regionUrl: null, limit: 10, @@ -461,6 +533,8 @@ describe("search_issues", () => { { organizationSlug: "nonexistent-org", naturalLanguageQuery: "test", + query: "is:unresolved", + sort: "date", projectSlugOrId: null, regionUrl: null, limit: 10, diff --git a/packages/mcp-core/src/tools/search-issues/handler.ts b/packages/mcp-core/src/tools/search-issues/handler.ts index 2aa99c263..ae5a3ef42 100644 --- a/packages/mcp-core/src/tools/search-issues/handler.ts +++ b/packages/mcp-core/src/tools/search-issues/handler.ts @@ -5,6 +5,8 @@ import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import type { ServerContext } from "../../types"; import { ParamOrganizationSlug, ParamRegionUrl } from "../../schema"; import { validateSlugOrId, isNumericId } from "../../utils/slug-validation"; +import { hasAgentProvider } from "../../internal/agents/provider-factory"; +import { ConfigurationError } from "../../errors"; import { searchIssuesAgent } from "./agent"; import { formatIssueResults, formatExplanation } from "./formatters"; @@ -15,33 +17,28 @@ export default defineTool({ description: [ "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.", "", - "Uses AI to translate natural language queries into Sentry issue search syntax.", - "Returns grouped issues with metadata like title, status, and user count.", - "", - "USE THIS TOOL WHEN USERS WANT:", - "- A LIST of issues: 'show me issues', 'what problems do we have'", - "- Filtered issue lists: 'unresolved issues', 'critical bugs'", - "- Issues by impact: 'errors affecting more than 100 users'", - "- Issues by assignment: 'issues assigned to me'", - "- User feedback: 'show me user feedback', 'feedback from last week'", + "Provide `naturalLanguageQuery` to let an embedded agent determine the correct query and sort params,", + "or use `query` and `sort` directly with Sentry search syntax.", "", - "DO NOT USE FOR COUNTS/AGGREGATIONS:", - "- 'how many errors' → use search_events", - "- 'count of issues' → use search_events", - "- 'total number of errors today' → use search_events", - "- 'sum/average/statistics' → use search_events", + "Returns grouped issues with metadata like title, status, and user count.", "", - "ALSO DO NOT USE FOR:", - "- Individual error events with timestamps → use search_events", - "- Details about a specific issue ID or Sentry issue URL → use get_sentry_resource", + "Common Query Syntax:", + "- is:unresolved / is:resolved / is:ignored", + "- level:error / level:warning", + "- firstSeen:-24h / lastSeen:-7d", + "- assigned:me / assignedOrSuggested:me", + "- issueCategory:feedback", + "- environment:production", + "- userCount:>100", "", - "REMEMBER: This tool returns a LIST of issues, not counts or statistics!", + "DO NOT USE FOR COUNTS/AGGREGATIONS → use search_events", + "DO NOT USE FOR individual events with timestamps → use search_events", + "DO NOT USE FOR details about a specific issue → use get_sentry_resource", "", "", "search_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')", - "search_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')", - "search_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')", - "search_issues(organizationSlug='my-org', naturalLanguageQuery='user feedback from production')", + "search_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')", + "search_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')", "", "", "", @@ -56,7 +53,23 @@ export default defineTool({ .string() .trim() .min(1) - .describe("Natural language description of issues to search for"), + .optional() + .describe( + "Natural language description of issues to search for. When provided, an embedded agent translates this into the correct query and sort params.", + ), + query: z + .string() + .trim() + .default("is:unresolved") + .describe( + "Sentry issue search query syntax. Used when naturalLanguageQuery is not provided.", + ), + sort: z + .enum(["date", "freq", "new", "user"]) + .default("date") + .describe( + "Sort order: date (last seen), freq (frequency), new (first seen), user (user count)", + ), projectSlugOrId: z .string() .toLowerCase() @@ -71,11 +84,13 @@ export default defineTool({ .min(1) .max(100) .default(10) - .describe("Maximum number of issues to return"), + .describe("Maximum number of issues to return (1-100)"), includeExplanation: z .boolean() .default(false) - .describe("Include explanation of how the query was translated"), + .describe( + "Include explanation of how the query was translated (only applies with naturalLanguageQuery)", + ), }, annotations: { readOnlyHint: true, @@ -88,7 +103,6 @@ export default defineTool({ setTag("organization.slug", params.organizationSlug); if (params.projectSlugOrId) { - // Check if it's a numeric ID or a slug and tag appropriately if (isNumericId(params.projectSlugOrId)) { setTag("project.id", params.projectSlugOrId); } else { @@ -96,80 +110,91 @@ export default defineTool({ } } - // Convert project slug to ID if needed - required for the agent's field discovery - let projectId: string | undefined; - if (params.projectSlugOrId) { - // Check if it's already a numeric ID - if (isNumericId(params.projectSlugOrId)) { - projectId = params.projectSlugOrId; - } else { - // It's a slug, convert to ID - const project = await apiService.getProject({ - organizationSlug: params.organizationSlug, - projectSlugOrId: params.projectSlugOrId!, - }); - projectId = String(project.id); + let query: string; + let sort: "date" | "freq" | "new" | "user"; + let explanation: string | undefined; + + if (params.naturalLanguageQuery) { + // NL mode: use embedded agent to refine params + if (!hasAgentProvider()) { + throw new ConfigurationError( + "Natural language search requires an AI provider (OPENAI_API_KEY or ANTHROPIC_API_KEY). " + + "Use the 'query' parameter with Sentry search syntax instead.", + ); } - } - // Translate natural language to Sentry query - const agentResult = await searchIssuesAgent({ - query: params.naturalLanguageQuery, - organizationSlug: params.organizationSlug, - apiService, - projectId, - }); + // Convert project slug to ID if needed - required for the agent's field discovery + let projectId: string | undefined; + if (params.projectSlugOrId) { + if (isNumericId(params.projectSlugOrId)) { + projectId = params.projectSlugOrId; + } else { + const project = await apiService.getProject({ + organizationSlug: params.organizationSlug, + projectSlugOrId: params.projectSlugOrId, + }); + projectId = String(project.id); + } + } - const translatedQuery = agentResult.result; + const agentResult = await searchIssuesAgent({ + query: params.naturalLanguageQuery, + organizationSlug: params.organizationSlug, + apiService, + projectId, + }); + + const translatedQuery = agentResult.result; + query = translatedQuery.query ?? params.query; + sort = translatedQuery.sort || params.sort; + explanation = translatedQuery.explanation; + } else { + // Direct mode: use Sentry query syntax params as-is + query = params.query; + sort = params.sort; + } - // Execute the search - listIssues accepts projectSlug directly const issues = await apiService.listIssues({ organizationSlug: params.organizationSlug, projectSlug: params.projectSlugOrId ?? undefined, - query: translatedQuery.query ?? undefined, - sortBy: translatedQuery.sort || "date", + query, + sortBy: sort, limit: params.limit, }); - // Build output with explanation first (if requested), then results + // Build output with explanation first (if requested and NL was used), then results let output = ""; - // Add explanation section before results (like search_events) - if (params.includeExplanation) { - // Start with title including natural language query + if (params.includeExplanation && params.naturalLanguageQuery) { output += `# Search Results for "${params.naturalLanguageQuery}"\n\n`; output += `⚠️ **IMPORTANT**: Display these issues as highlighted cards with status indicators, assignee info, and clickable Issue IDs.\n\n`; output += `## Query Translation\n`; output += `Natural language: "${params.naturalLanguageQuery}"\n`; - output += `Sentry query: \`${translatedQuery.query}\``; - if (translatedQuery.sort) { - output += `\nSort: ${translatedQuery.sort}`; - } + output += `Sentry query: \`${query}\``; + output += `\nSort: ${sort}`; output += `\n\n`; - if (translatedQuery.explanation) { - output += formatExplanation(translatedQuery.explanation); + if (explanation) { + output += formatExplanation(explanation); output += `\n\n`; } - // Format results without the header since we already added it output += formatIssueResults({ issues, organizationSlug: params.organizationSlug, projectSlugOrId: params.projectSlugOrId ?? undefined, - query: translatedQuery.query, + query, regionUrl: params.regionUrl ?? undefined, naturalLanguageQuery: params.naturalLanguageQuery, skipHeader: true, }); } else { - // Format results with natural language query for title output = formatIssueResults({ issues, organizationSlug: params.organizationSlug, projectSlugOrId: params.projectSlugOrId ?? undefined, - query: translatedQuery.query, + query, regionUrl: params.regionUrl ?? undefined, naturalLanguageQuery: params.naturalLanguageQuery, skipHeader: false, diff --git a/packages/mcp-core/src/tools/use-sentry/handler.test.ts b/packages/mcp-core/src/tools/use-sentry/handler.test.ts index ffc84cc77..08287e71d 100644 --- a/packages/mcp-core/src/tools/use-sentry/handler.test.ts +++ b/packages/mcp-core/src/tools/use-sentry/handler.test.ts @@ -59,7 +59,7 @@ describe("use_sentry handler", () => { }), }); - // Verify all tools were provided (total - use_sentry - 3 list_* tools - internal-only detail tools) + // Verify all tools were provided (total - use_sentry - internal-only detail tools) const toolsArg = mockUseSentryAgent.mock.calls[0][0].tools; expect(Object.keys(toolsArg)).toHaveLength(22); @@ -110,7 +110,7 @@ describe("use_sentry handler", () => { // Verify use_sentry is NOT in the list expect(toolNames).not.toContain("use_sentry"); - // Verify tool count (total - use_sentry - 3 list_* tools - internal-only detail tools) + // Verify tool count (total - use_sentry - internal-only detail tools) expect(toolNames).toHaveLength(22); }); diff --git a/packages/mcp-core/src/tools/use-sentry/handler.ts b/packages/mcp-core/src/tools/use-sentry/handler.ts index 2b5dc1be4..569acc663 100644 --- a/packages/mcp-core/src/tools/use-sentry/handler.ts +++ b/packages/mcp-core/src/tools/use-sentry/handler.ts @@ -5,7 +5,7 @@ import { defineTool } from "../../internal/tool-helpers/define"; import type { ServerContext } from "../../types"; import { useSentryAgent } from "./agent"; import { buildServer } from "../../server"; -import tools, { SIMPLE_REPLACEMENT_TOOLS } from "../index"; +import tools from "../index"; import { isToolVisibleInMode } from "../types"; import type { ToolCall } from "../../internal/agents/callEmbeddedAgent"; @@ -94,14 +94,10 @@ export default defineTool({ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Exclude use_sentry (to prevent recursion) and simple replacement tools - // (since use_sentry only runs when an agent provider is available, list_* tools aren't needed). + // Exclude use_sentry to prevent recursion. // The visibility helper also strips internalOnly tools, so the embedded // agent uses get_sentry_resource instead of legacy detail handlers. - const toolsToExclude = new Set([ - "use_sentry", - ...SIMPLE_REPLACEMENT_TOOLS, - ]); + const toolsToExclude = new Set(["use_sentry"]); const toolsForAgent = Object.fromEntries( Object.entries(tools).filter( ([key, tool]) => diff --git a/packages/mcp-server-evals/src/evals/list-issues.eval.ts b/packages/mcp-server-evals/src/evals/list-issues.eval.ts index 3452a51f3..64295d64c 100644 --- a/packages/mcp-server-evals/src/evals/list-issues.eval.ts +++ b/packages/mcp-server-evals/src/evals/list-issues.eval.ts @@ -12,12 +12,11 @@ describeEval("list-issues", { arguments: {}, }, { - name: "find_issues", + name: "search_issues", arguments: { organizationSlug: FIXTURES.organizationSlug, query: "is:unresolved", - sortBy: "count", - regionUrl: "https://us.sentry.io", + sort: "freq", }, }, ], @@ -30,11 +29,10 @@ describeEval("list-issues", { arguments: {}, }, { - name: "find_issues", + name: "search_issues", arguments: { organizationSlug: FIXTURES.organizationSlug, - sortBy: "count", - regionUrl: "https://us.sentry.io", + sort: "freq", }, }, ], @@ -47,11 +45,10 @@ describeEval("list-issues", { arguments: {}, }, { - name: "find_issues", + name: "search_issues", arguments: { organizationSlug: FIXTURES.organizationSlug, - sortBy: "last_seen", - regionUrl: "https://us.sentry.io", + sort: "date", }, }, ], @@ -64,11 +61,10 @@ describeEval("list-issues", { arguments: {}, }, { - name: "find_issues", + name: "search_issues", arguments: { organizationSlug: FIXTURES.organizationSlug, - sortBy: "first_seen", - regionUrl: "https://us.sentry.io", + sort: "new", }, }, ], @@ -81,11 +77,10 @@ describeEval("list-issues", { arguments: {}, }, { - name: "find_issues", + name: "search_issues", arguments: { organizationSlug: FIXTURES.organizationSlug, query: "user.email:david@sentry.io", - regionUrl: "https://us.sentry.io", }, }, ], diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 3a33c8bd9..af8523219 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -201,13 +201,10 @@ async function main() { "Warning: No LLM API key found (OPENAI_API_KEY or ANTHROPIC_API_KEY).", ); console.warn( - "The following AI-powered search tools will be unavailable:", + "The naturalLanguageQuery feature and use_sentry tool will be unavailable.", ); console.warn( - " - search_events, search_issues, search_issue_events, use_sentry", - ); - console.warn( - "Use list_issues and list_events for direct Sentry query syntax instead.", + "Search tools still work with direct Sentry query syntax via the 'query' parameter.", ); } console.warn(""); diff --git a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md index e17179784..f806a7aea 100644 --- a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md +++ b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md @@ -22,9 +22,6 @@ allowedTools: - get_profile_details - get_replay_details - get_sentry_resource - - list_events - - list_issue_events - - list_issues - search_docs - search_events - search_issue_events @@ -48,7 +45,7 @@ You are a Sentry expert. Investigate errors, analyze performance, and manage pro - `search_issues` returns grouped issue lists. `search_events` returns counts, aggregations, or individual event rows. - `get_sentry_resource` fetches a known issue, event, trace, or breadcrumbs from a Sentry URL or resource ID. `analyze_issue_with_seer` provides AI root cause analysis with code fixes. -- `list_events` accepts raw Sentry query syntax. `search_events` accepts natural language. +- `search_events` accepts both `naturalLanguageQuery` (agent-assisted) and direct Sentry query syntax via `query`/`dataset`/`fields` params. Same for `search_issues` and `search_issue_events`. ## Output diff --git a/plugins/sentry-mcp/agents/sentry-mcp.md b/plugins/sentry-mcp/agents/sentry-mcp.md index e17179784..f806a7aea 100644 --- a/plugins/sentry-mcp/agents/sentry-mcp.md +++ b/plugins/sentry-mcp/agents/sentry-mcp.md @@ -22,9 +22,6 @@ allowedTools: - get_profile_details - get_replay_details - get_sentry_resource - - list_events - - list_issue_events - - list_issues - search_docs - search_events - search_issue_events @@ -48,7 +45,7 @@ You are a Sentry expert. Investigate errors, analyze performance, and manage pro - `search_issues` returns grouped issue lists. `search_events` returns counts, aggregations, or individual event rows. - `get_sentry_resource` fetches a known issue, event, trace, or breadcrumbs from a Sentry URL or resource ID. `analyze_issue_with_seer` provides AI root cause analysis with code fixes. -- `list_events` accepts raw Sentry query syntax. `search_events` accepts natural language. +- `search_events` accepts both `naturalLanguageQuery` (agent-assisted) and direct Sentry query syntax via `query`/`dataset`/`fields` params. Same for `search_issues` and `search_issue_events`. ## Output From 691a063b9dc74eb1d1f1ac84221d331773031bcb Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 3 Apr 2026 12:53:22 -0700 Subject: [PATCH 2/2] fix(tests): Restore ANTHROPIC_API_KEY in ConfigurationError tests Save and restore both OPENAI_API_KEY and ANTHROPIC_API_KEY in the ConfigurationError test teardown to prevent env var pollution across test runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/mcp-core/src/tools/search-events.test.ts | 6 ++++-- .../mcp-core/src/tools/search-issue-events.test.ts | 6 ++++-- packages/mcp-core/src/tools/search-issues.test.ts | 10 ++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/mcp-core/src/tools/search-events.test.ts b/packages/mcp-core/src/tools/search-events.test.ts index 5eae0aab9..adfe9e896 100644 --- a/packages/mcp-core/src/tools/search-events.test.ts +++ b/packages/mcp-core/src/tools/search-events.test.ts @@ -703,7 +703,8 @@ describe("search_events", () => { }); it("should throw ConfigurationError when naturalLanguageQuery provided without agent", async () => { - const savedKey = process.env.OPENAI_API_KEY; + const savedOpenAI = process.env.OPENAI_API_KEY; + const savedAnthropic = process.env.ANTHROPIC_API_KEY; process.env.OPENAI_API_KEY = ""; process.env.ANTHROPIC_API_KEY = ""; @@ -735,7 +736,8 @@ describe("search_events", () => { ), ).rejects.toThrow(ConfigurationError); } finally { - process.env.OPENAI_API_KEY = savedKey; + process.env.OPENAI_API_KEY = savedOpenAI; + process.env.ANTHROPIC_API_KEY = savedAnthropic; } }); }); diff --git a/packages/mcp-core/src/tools/search-issue-events.test.ts b/packages/mcp-core/src/tools/search-issue-events.test.ts index 6cfce9e76..03033e4fb 100644 --- a/packages/mcp-core/src/tools/search-issue-events.test.ts +++ b/packages/mcp-core/src/tools/search-issue-events.test.ts @@ -581,7 +581,8 @@ describe("search_issue_events", () => { }); it("should throw ConfigurationError when naturalLanguageQuery provided without agent", async () => { - const savedKey = process.env.OPENAI_API_KEY; + const savedOpenAI = process.env.OPENAI_API_KEY; + const savedAnthropic = process.env.ANTHROPIC_API_KEY; process.env.OPENAI_API_KEY = ""; process.env.ANTHROPIC_API_KEY = ""; @@ -604,7 +605,8 @@ describe("search_issue_events", () => { ), ).rejects.toThrow(ConfigurationError); } finally { - process.env.OPENAI_API_KEY = savedKey; + process.env.OPENAI_API_KEY = savedOpenAI; + process.env.ANTHROPIC_API_KEY = savedAnthropic; } }); }); diff --git a/packages/mcp-core/src/tools/search-issues.test.ts b/packages/mcp-core/src/tools/search-issues.test.ts index aec7bf5ba..4262c6974 100644 --- a/packages/mcp-core/src/tools/search-issues.test.ts +++ b/packages/mcp-core/src/tools/search-issues.test.ts @@ -169,7 +169,8 @@ describe("search_issues", () => { }); it("should throw ConfigurationError when naturalLanguageQuery provided without agent", async () => { - const savedKey = process.env.OPENAI_API_KEY; + const savedOpenAI = process.env.OPENAI_API_KEY; + const savedAnthropic = process.env.ANTHROPIC_API_KEY; process.env.OPENAI_API_KEY = ""; process.env.ANTHROPIC_API_KEY = ""; @@ -190,7 +191,12 @@ describe("search_issues", () => { ), ).rejects.toThrow(ConfigurationError); } finally { - process.env.OPENAI_API_KEY = savedKey; + process.env.OPENAI_API_KEY = savedOpenAI; + if (savedAnthropic === undefined) { + process.env.ANTHROPIC_API_KEY = ""; + } else { + process.env.ANTHROPIC_API_KEY = savedAnthropic; + } } });