From cafdf868b0a202719df3b2577385690930c4357b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:53:27 +0000 Subject: [PATCH 1/2] feat(search): Split search_events into dataset-specific tools Replace the multi-dataset search_events tool on the direct MCP surface with one tool per dataset: search_errors, search_logs, search_traces, search_metrics, search_profiles, and search_replays. Tool selection now picks the dataset, so agents can no longer omit or mis-pick a `dataset` argument (which silently fell back to errors and skipped Seer). All six tools share one handler, moved to tools/support/search-events/search.ts. Each tool fixes its dataset and passes lockDataset so the embedded agent cannot switch datasets. search_traces documents same-trace cross-event queries, since Seer only runs cross-event filters for the Traces strategy. search_events stays in the catalog as a deprecated alias for backward compatibility and is excluded from skill definitions. Co-Authored-By: Shaun Kaasten --- README.md | 2 +- docs/architecture/overview.md | 4 +- docs/contributing/adding-tools.md | 2 +- docs/operations/embedded-agents.md | 2 +- docs/specs/search-events.md | 75 +- docs/testing/overview.md | 2 +- docs/testing/stdio.md | 10 +- .../components/fragments/stdio-setup.tsx | 7 +- .../src/server/lib/mcp-handler.test.ts | 8 +- .../routes/__tests__/mcp-discovery.test.ts | 3 +- packages/mcp-core/README.md | 2 +- packages/mcp-core/src/internal/formatting.ts | 6 +- .../src/internal/tool-helpers/seer.test.ts | 2 +- .../src/internal/tool-helpers/seer.ts | 2 +- packages/mcp-core/src/server.test.ts | 7 +- packages/mcp-core/src/skillDefinitions.json | 105 +- packages/mcp-core/src/toolDefinitions.json | 537 +++++++- .../catalog/analyze-issue-with-seer.test.ts | 2 +- .../tools/catalog/analyze-issue-with-seer.ts | 2 +- .../tools/catalog/get-issue-details.test.ts | 16 +- .../tools/catalog/get-latest-base-snapshot.ts | 2 +- .../tools/catalog/get-profile-details.test.ts | 18 +- .../src/tools/catalog/get-profile.test.ts | 4 +- .../mcp-core/src/tools/catalog/get-profile.ts | 4 +- .../src/tools/catalog/get-sentry-resource.ts | 2 +- .../tools/catalog/get-span-details.test.ts | 6 +- .../tools/catalog/get-trace-details.test.ts | 33 +- .../src/tools/catalog/get-trace-details.ts | 19 +- packages/mcp-core/src/tools/catalog/index.ts | 12 + .../src/tools/catalog/search-errors.test.ts | 131 ++ .../src/tools/catalog/search-errors.ts | 38 + .../search-events-environment-note.test.ts | 2 +- .../src/tools/catalog/search-events.ts | 1017 +-------------- .../src/tools/catalog/search-issues.test.ts | 2 +- .../src/tools/catalog/search-issues.ts | 4 +- .../src/tools/catalog/search-logs.test.ts | 131 ++ .../mcp-core/src/tools/catalog/search-logs.ts | 38 + .../src/tools/catalog/search-metrics.test.ts | 131 ++ .../src/tools/catalog/search-metrics.ts | 37 + .../src/tools/catalog/search-profiles.test.ts | 131 ++ .../src/tools/catalog/search-profiles.ts | 34 + .../src/tools/catalog/search-replays.test.ts | 136 ++ .../src/tools/catalog/search-replays.ts | 34 + .../src/tools/catalog/search-traces.test.ts | 209 +++ .../src/tools/catalog/search-traces.ts | 40 + .../src/tools/support/profile/formatter.ts | 2 +- .../src/tools/support/search-events/search.ts | 1124 +++++++++++++++++ .../support/search-issues/formatters.test.ts | 2 +- .../tools/support/search-issues/formatters.ts | 6 +- packages/mcp-core/src/tools/surfaces.ts | 7 +- .../src/evals/search-events.eval.ts | 17 +- .../src/evals/utils/toolPredictionScorer.ts | 2 +- .../agents/sentry-mcp.md | 15 +- plugins/sentry-mcp/agents/sentry-mcp.md | 15 +- 54 files changed, 3039 insertions(+), 1162 deletions(-) create mode 100644 packages/mcp-core/src/tools/catalog/search-errors.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-errors.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-logs.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-logs.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-metrics.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-metrics.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-profiles.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-profiles.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-replays.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-replays.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-traces.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/search-traces.ts create mode 100644 packages/mcp-core/src/tools/support/search-events/search.ts diff --git a/README.md b/README.md index da02ee6eb..d44a4d450 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ claude plugin install sentry-mcp@sentry-mcp-experimental While this repository is focused on acting as an MCP service, we also support a `stdio` transport. This is still a work in progress, but is the easiest way to adapt run the MCP against a self-hosted Sentry install. -**Note:** The AI-powered search tools (`search_events`, `search_issues`, etc.) require an LLM provider (OpenAI, Azure OpenAI, Anthropic, or OpenRouter). These tools use natural language processing to translate queries into Sentry's query syntax. Without a configured provider, these specific tools will be unavailable, but all other tools will function normally. +**Note:** The AI-powered search tools (`search_errors`, `search_traces`, `search_logs`, `search_issues`, etc.) require an LLM provider (OpenAI, Azure OpenAI, Anthropic, or OpenRouter). These tools use natural language processing to translate queries into Sentry's query syntax. Without a configured provider, these specific tools will be unavailable, but all other tools will function normally. To utilize the `stdio` transport, you'll need to create an User Auth Token in Sentry with the necessary scopes. As of writing this is: diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3283d4bb2..9692f1f17 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -285,7 +285,7 @@ Execute actions and retrieve data: ## Two-Tier Agent Architecture -Some tools (`search_events` and `search_issues`) implement a two-tier agent pattern: +Some tools (the dataset search tools like `search_errors`/`search_traces`, and `search_issues`) implement a two-tier agent pattern: ### Tier 1: Calling Agent (Claude/Cursor) - Decides when to use search tools @@ -304,7 +304,7 @@ Some tools (`search_events` and `search_issues`) implement a two-tier agent patt ``` 1. User: "Show me errors from yesterday" ↓ -2. Claude: Calls search_events(query="errors from yesterday") +2. Claude: Calls search_errors(query="errors from yesterday") ↓ 3. MCP Tool Handler: Receives request ↓ diff --git a/docs/contributing/adding-tools.md b/docs/contributing/adding-tools.md index f39d36b4a..1200ff85f 100644 --- a/docs/contributing/adding-tools.md +++ b/docs/contributing/adding-tools.md @@ -318,7 +318,7 @@ pnpm eval your-tool ## Agent-in-Tool Pattern -Some tools (`search_events`, `search_issue_events`, and `search_issues`) embed +Some tools (the dataset search tools such as `search_errors`, plus `search_issue_events` and `search_issues`) embed AI agents to normalize search parameters before the handler calls Sentry. Treat the agent as a repair step for a structured request, not only as a natural language query translator. The agent may rewrite the query string, but it may diff --git a/docs/operations/embedded-agents.md b/docs/operations/embedded-agents.md index 8a9ecd3f7..da36ebd34 100644 --- a/docs/operations/embedded-agents.md +++ b/docs/operations/embedded-agents.md @@ -5,7 +5,7 @@ Configuration guide for embedded AI agents used by AI-powered search tools in Se ## Overview Sentry MCP uses embedded AI agents for the following tools: -- `search_events` - Natural language search across events, metrics, and session replays +- `search_errors`, `search_logs`, `search_traces`, `search_metrics`, `search_profiles`, `search_replays` - Natural language search over one dataset each (share one handler; `search_events` remains as a deprecated catalog alias) - `search_issues` - Natural language search across issues - `search_issue_events` - Search events within a specific issue diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 6a9868c13..8f9e283ac 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -1,58 +1,79 @@ -# search_events Tool Specification +# Dataset Search Tools Specification ## Overview -A unified search tool that accepts natural language queries and translates them to Sentry's discover endpoint parameters using the configured embedded LLM provider. Replaces `find_errors` and `find_transactions` with a single, more flexible interface. +Natural-language event search is exposed as one tool per dataset: + +| Tool | Dataset | Seer strategy | +| --- | --- | --- | +| `search_errors` | `errors` | `Errors` | +| `search_logs` | `logs` | `Logs` | +| `search_traces` | `spans` | `Traces` | +| `search_metrics` | `metrics` | `Metrics` | +| `search_profiles` | `profiles` | — | +| `search_replays` | `replays` | — | + +All six share one handler (`tools/support/search-events/search.ts`). Each tool +fixes its dataset, so the caller never chooses a `dataset` parameter and the +embedded agent is told it cannot switch datasets (`lockDataset`). This removes +the most common routing mistake from the old multi-dataset `search_events` tool, +where clients omitted `dataset`, silently got `errors`, and skipped Seer. + +`search_events` stays in the catalog as a deprecated alias for backward +compatibility (reachable via `execute_sentry_tool`). It is no longer on the +direct MCP surface and is excluded from skill definitions. ## Motivation -- **Before**: Two separate tools with rigid parameters, users must know Sentry query syntax -- **After**: Single tool with natural language input, AI handles translation to Sentry syntax -- **Benefits**: Better UX, reduced tool count (20 → 19), accessible to non-technical users +- **Before**: One `search_events` tool with an optional `dataset` enum; agents + often omitted or mis-picked it. +- **After**: Tool selection picks the dataset. Each description only documents + its own dataset, so descriptions are shorter and more specific. +- **Cross-event**: same-trace co-occurrence questions ("slow checkout requests + that also logged an error") route to `search_traces`, the only Seer strategy + that supports cross-event filters. ## Interface ```typescript -interface SearchEventsParams { - organizationSlug: string; // Required - query: string; // Natural language search description - dataset?: "spans" | "errors" | "logs" | "metrics"; // Dataset to search (default: "errors") - projectSlug?: string; // Optional - limit to specific project - regionUrl?: string; +// search_errors / search_logs / search_traces / search_metrics / search_profiles +interface DatasetSearchParams { + organizationSlug: string; + query?: string; // Natural language (preferred) or Sentry search syntax + projectSlug?: string; + fields?: string[]; + sort?: string; + period?: string; // e.g. "24h", "7d" limit?: number; // Default: 10, Max: 100 - includeExplanation?: boolean; // Include translation explanation + includeExplanation?: boolean; + regionUrl?: string; } + +// search_replays drops `fields` and adds a separate `environment` parameter. ``` ### Examples ```typescript -// Find errors (errors dataset is default) -search_events({ +search_errors({ organizationSlug: "my-org", query: "database timeouts in checkout flow from last hour" }) -// Find slow transactions -search_events({ +search_traces({ organizationSlug: "my-org", query: "API calls taking over 5 seconds", - projectSlug: "backend", - dataset: "spans" + projectSlug: "backend" }) -// Find logs -search_events({ +search_logs({ organizationSlug: "my-org", - query: "warning logs about memory usage", - dataset: "logs" + query: "warning logs about memory usage" }) -// Find request duration metrics -search_events({ +search_metrics({ organizationSlug: "my-org", - query: "p95 request duration by transaction this week", - dataset: "metrics" + query: "p95 request duration by transaction this week" }) ``` @@ -150,7 +171,7 @@ find_errors({ }) // After -search_events({ +search_errors({ organizationSlug: "sentry", query: "unresolved errors in checkout.js" }) diff --git a/docs/testing/overview.md b/docs/testing/overview.md index 501881b5f..984018f22 100644 --- a/docs/testing/overview.md +++ b/docs/testing/overview.md @@ -115,7 +115,7 @@ pnpm -w run cli --access-token=TOKEN "query" - Testing OAuth flows - Debugging tool interactions - Validating real API responses -- Testing AI-powered tools (search_events, search_issues, search_issue_events) +- Testing AI-powered tools (search_errors/search_traces/search_logs etc., search_issues, search_issue_events) **Note:** The CLI defaults to `http://localhost:5173` for easier local development. Override with `--mcp-host` or set `MCP_URL` environment variable to test against different servers. diff --git a/docs/testing/stdio.md b/docs/testing/stdio.md index 8a1aa0076..ef0c35e29 100644 --- a/docs/testing/stdio.md +++ b/docs/testing/stdio.md @@ -198,14 +198,14 @@ This opens the MCP Inspector at `http://localhost:6274` 1. **List Tools** - Verify expected tools appear 2. **Call a tool** - Start with `execute_sentry_tool` using `name="whoami"` and `arguments={}` 3. **Test with parameters** - Try `find_organizations()` -4. **Test complex operations** - Try `search_events(query="errors in the last hour")` +4. **Test complex operations** - Try `search_errors(query="errors in the last hour")` **Example test sequence:** ``` 1. execute_sentry_tool(name="whoami", arguments={}) 2. find_organizations() 3. find_projects(organizationSlug="your-org") -4. search_events( +4. search_errors( organizationSlug="your-org", query="errors from yesterday" ) @@ -425,7 +425,7 @@ SENTRY_HOST=sentry.example.com MCP_SKILLS=inspect,docs,triage # Limit to specific skills # AI features -OPENAI_API_KEY=your-key # For AI-powered search tools like search_events/search_issues +OPENAI_API_KEY=your-key # For AI-powered search tools like search_errors/search_traces/search_issues # Sentry reporting SENTRY_DSN=your-dsn @@ -481,9 +481,9 @@ pnpm start --access-token=TOKEN --skills=inspect,seer,docs # With OpenAI API key OPENAI_API_KEY=your-key pnpm start --access-token=TOKEN -# Test search_events and search_issues work +# Test the dataset search tools and search_issues work # In MCP Inspector: -# - Call search_events(query="errors in production") +# - Call search_errors(query="errors in production") # - Call search_issues(query="unresolved crashes") ``` diff --git a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx index 303edc10d..8addfdcd0 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx @@ -47,7 +47,9 @@ export default function StdioSetup() {

AI-powered search: If you want the - search_events and search_issues tools to + event search tools (search_errors,{" "} + search_traces, search_logs, and so on) and{" "} + search_issues to translate natural language queries, add an OPENAI_API_KEY next to your Sentry token. The rest of the MCP server works without it, so you can skip this step if you do not @@ -113,7 +115,8 @@ export default function StdioSetup() {

Optional for the standard tools, but required for the AI-powered - search tools (search_events /{" "} + search tools (search_errors,{" "} + search_traces, search_logs, etc. and{" "} search_issues). When unset, those tools stay hidden but everything else works as usual.
diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts index 4520c2711..f0535a2d5 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts @@ -288,7 +288,7 @@ describe("MCP Handler", () => { }>(response); const toolNames = body.result?.tools.map((tool) => tool.name) ?? []; - expect(toolNames).toContain("search_events"); + expect(toolNames).toContain("search_traces"); expect(toolNames).not.toContain("search_docs"); }); @@ -343,7 +343,7 @@ describe("MCP Handler", () => { }>(response); const toolNames = body.result?.tools.map((tool) => tool.name) ?? []; - expect(toolNames).toContain("search_events"); + expect(toolNames).toContain("search_traces"); expect(toolNames).toContain("update_issue"); }); @@ -480,7 +480,7 @@ describe("MCP Handler", () => { }>(response); const toolNames = body.result?.tools.map((tool) => tool.name) ?? []; - expect(toolNames).toContain("search_events"); + expect(toolNames).toContain("search_traces"); expect(toolNames).not.toContain("update_issue"); }); @@ -508,7 +508,7 @@ describe("MCP Handler", () => { }>(response); const toolNames = body.result?.tools.map((tool) => tool.name) ?? []; - expect(toolNames).toContain("search_events"); + expect(toolNames).toContain("search_traces"); expect(toolNames).not.toContain("update_issue"); }); diff --git a/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts index d6f8d5a40..f321d4356 100644 --- a/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts @@ -53,11 +53,12 @@ describe("/.mcp discovery routes", () => { surface: "direct", }), ); - expect(toolsByName.get("search_events")).toEqual( + expect(toolsByName.get("search_traces")).toEqual( expect.objectContaining({ surface: "direct", }), ); + expect(toolsByName.get("search_events")?.surface).not.toBe("direct"); expect(toolsByName.get("get_issue_details")).toEqual( expect.objectContaining({ inputSchema: expect.any(Object), diff --git a/packages/mcp-core/README.md b/packages/mcp-core/README.md index 67bb95e4f..6d095f96a 100644 --- a/packages/mcp-core/README.md +++ b/packages/mcp-core/README.md @@ -7,7 +7,7 @@ This package is primarily for running the `stdio` MCP server. If you do not know **Note:** Some tools require additional configuration: -- **AI-powered search tools** (`search_events` and `search_issues`): These tools use a configured LLM provider to translate natural language queries into Sentry's query syntax. Set one provider key, such as `OPENAI_API_KEY` or `OPENROUTER_API_KEY`. Without a provider key, these specific tools will be unavailable, but all other tools will function normally. +- **AI-powered search tools** (the dataset search tools such as `search_errors`/`search_traces`/`search_logs`, and `search_issues`): These tools use a configured LLM provider to translate natural language queries into Sentry's query syntax. Set one provider key, such as `OPENAI_API_KEY` or `OPENROUTER_API_KEY`. Without a provider key, these specific tools will be unavailable, but all other tools will function normally. ## Authorization diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index d81f91a15..4f2c12fde 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -2326,10 +2326,9 @@ export function formatIssueOutput({ "Full distributed trace lookup is not available in this session", }); const spanSearchInstruction = formatToolCallInstruction({ - toolName: "search_events", + toolName: "search_traces", arguments: { organizationSlug, - dataset: "spans", query: `trace:${traceId}`, }, experimentalMode: experimentalMode ?? false, @@ -2339,10 +2338,9 @@ export function formatIssueOutput({ "Related span search is not available in this session", }); const logSearchInstruction = formatToolCallInstruction({ - toolName: "search_events", + toolName: "search_logs", arguments: { organizationSlug, - dataset: "logs", query: `trace:${traceId}`, }, experimentalMode: experimentalMode ?? false, diff --git a/packages/mcp-core/src/internal/tool-helpers/seer.test.ts b/packages/mcp-core/src/internal/tool-helpers/seer.test.ts index 6dd58052f..a9b36840e 100644 --- a/packages/mcp-core/src/internal/tool-helpers/seer.test.ts +++ b/packages/mcp-core/src/internal/tool-helpers/seer.test.ts @@ -99,7 +99,7 @@ describe("seer-utils", () => { expect(message).toContain("Seer Analysis Not Available"); expect(message).toContain("MCP-SERVER-EQE"); - expect(message).toContain("search_events"); + expect(message).toContain("search_metrics"); expect(message).not.toContain("Starting new analysis"); }); }); diff --git a/packages/mcp-core/src/internal/tool-helpers/seer.ts b/packages/mcp-core/src/internal/tool-helpers/seer.ts index da65ae4dd..96293e31d 100644 --- a/packages/mcp-core/src/internal/tool-helpers/seer.ts +++ b/packages/mcp-core/src/internal/tool-helpers/seer.ts @@ -42,7 +42,7 @@ export function getSeerUnsupportedIssueMessage( "**Suggested alternatives:**", "- Use `get_issue_details` or `get_sentry_resource` to inspect the metric alert rule and threshold details", "- Use `search_issues` to find related error issues that may explain the metric spike", - "- Use `search_events` to query the underlying metric data", + "- Use `search_metrics` (or `search_traces` for span-based alerts) to query the underlying data", ].join("\n"); } diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index ae3a85e14..3370d3a23 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -120,9 +120,14 @@ const DEFAULT_DIRECT_TOOL_NAMES = [ "find_organizations", "find_projects", "get_sentry_resource", - "search_events", + "search_errors", "search_issues", + "search_logs", + "search_metrics", + "search_profiles", + "search_replays", "search_sentry_tools", + "search_traces", "update_issue", ].sort(); diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 11cba76f5..3f7636d28 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -5,7 +5,7 @@ "description": "Read-only access to core Sentry data: issues, events, traces, replays, releases, cron monitors, uptime monitors, metric monitors, profiles, documentation, and project metadata", "defaultEnabled": true, "order": 1, - "toolCount": 41, + "toolCount": 46, "tools": [ { "name": "find_alert_rules", @@ -114,7 +114,7 @@ }, { "name": "get_latest_base_snapshot", - "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots — not search_events or search_issues.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", + "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots — not search_replays or search_issues.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", "requiredScopes": ["project:read"] }, { @@ -174,7 +174,7 @@ }, { "name": "get_trace_details", - "description": "Get detailed information about a specific Sentry trace by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')\n- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'\n- Want high-level overview and link to view trace details in Sentry\n- Need trace statistics and span breakdown\n- Want an overview first, then a guided pivot into additional spans or events\n\nDO NOT USE for:\n- General searching for traces (use search_events with trace queries)\n- Complete span enumeration or branch-by-branch reconstruction (use search_events scoped to the trace)\n\nTRIGGER PATTERNS:\n- 'Show me trace abc123' → use get_trace_details\n- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details\n- 'What is trace [trace-id]' → use get_trace_details\n\n\n### Get trace overview\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')\n```\n\n### Focus a single span\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a', spanId='aa8e7f3384ef4ff5')\n```\n\n\n\n- Trace IDs are 32-character hexadecimal strings\n- This returns a condensed trace overview, not a full span dump\n- Provide `spanId` to focus on a single span within the trace\n- If the response says it shows a subset of spans, use search_events to inspect the rest of the trace\n", + "description": "Get detailed information about a specific Sentry trace by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')\n- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'\n- Want high-level overview and link to view trace details in Sentry\n- Need trace statistics and span breakdown\n- Want an overview first, then a guided pivot into additional spans or events\n\nDO NOT USE for:\n- General searching for traces (use search_traces)\n- Complete span enumeration or branch-by-branch reconstruction (use search_traces scoped to the trace)\n\nTRIGGER PATTERNS:\n- 'Show me trace abc123' → use get_trace_details\n- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details\n- 'What is trace [trace-id]' → use get_trace_details\n\n\n### Get trace overview\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')\n```\n\n### Focus a single span\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a', spanId='aa8e7f3384ef4ff5')\n```\n\n\n\n- Trace IDs are 32-character hexadecimal strings\n- This returns a condensed trace overview, not a full span dump\n- Provide `spanId` to focus on a single span within the trace\n- If the response says it shows a subset of spans, use search_traces to inspect the rest of the trace\n", "requiredScopes": ["event:read"] }, { @@ -193,8 +193,8 @@ "requiredScopes": [] }, { - "name": "search_events", - "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', dataset='errors', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", + "name": "search_errors", + "description": "Search Sentry error events: exceptions and crashes with stack traces. Use for error counts, statistics, trends, and individual error events.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('how many errors today', 'top error types'), individual events ('latest TypeErrors in checkout'), and time series ('errors per hour last 24h').\n\nNOT for log messages, including error/warning logs (use search_logs), requests or latency (use search_traces), or grouped issue lists (use search_issues).\n\n\nsearch_errors(organizationSlug='my-org', query='how many errors today')\nsearch_errors(organizationSlug='my-org', query='most common error types in production this week')\nsearch_errors(organizationSlug='my-org', query='errors per hour last 24h')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", "requiredScopes": ["event:read"] }, { @@ -204,7 +204,32 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit 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 / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\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', query='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", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit 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 / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='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"] + }, + { + "name": "search_logs", + "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_metrics", + "description": "Search Sentry metrics: counters, gauges, and distributions, as rows or aggregates. Use for metric values, percentiles, totals, and trends.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('p95 http.request.duration by environment'), individual metric rows, and time series ('total tokens per day this week').\n\nNOT for span or request latency recorded on traces (use search_traces).\n\n\nsearch_metrics(organizationSlug='my-org', query='p95 http.request.duration grouped by environment over the last 24 hours')\nsearch_metrics(organizationSlug='my-org', query='total tokens used per day this week')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_profiles", + "description": "Search Sentry profiles: transaction and continuous profile results, profile IDs, and profiled transactions. Use to find profiles to inspect.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nUse get_profile or get_profile_details on a result for flamegraph and hotspot analysis.\n\n\nsearch_profiles(organizationSlug='my-org', query='recent profiles for the /checkout transaction')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_replays", + "description": "Search Sentry session replays: rage clicks, dead clicks, visited pages, errors seen, and replay users.\n\n`query` is natural language (preferred) or replay search syntax; a configured agent translates it into a replay search.\n\nReturns replay lists only; count()/avg()/sum() are not supported. Use get_replay_details on a result for one replay.\n\n\nsearch_replays(organizationSlug='my-org', query='replays with rage clicks on checkout in the last day')\nsearch_replays(organizationSlug='my-org', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_traces", + "description": "Search Sentry spans and traces: requests, API/HTTP calls, endpoints, DB queries, AI/LLM calls, and other operations. Use for latency, throughput, slowness, and performance questions.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nAlso use for spans whose trace contains a matching log, metric, or other span, e.g. 'slow checkout requests that also logged an error'.\n\nSupports aggregations ('p95 duration by span.op'), individual spans ('slowest API calls today'), and time series ('requests per minute last hour').\n\nNOT for exceptions/crashes (use search_errors), standalone log lines (use search_logs), or a single trace's span tree (use get_sentry_resource with the trace).\n\n\nsearch_traces(organizationSlug='my-org', query='slowest API calls in the last 24 hours')\nsearch_traces(organizationSlug='my-org', query='p95 duration of db spans grouped by span.op over 7 days')\nsearch_traces(organizationSlug='my-org', query='checkout requests that also have an error log')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", "requiredScopes": ["event:read"] }, { @@ -220,11 +245,11 @@ "description": "Sentry's AI debugger that helps you analyze, root cause, and fix issues", "defaultEnabled": true, "order": 2, - "toolCount": 12, + "toolCount": 17, "tools": [ { "name": "analyze_issue_with_seer", - "description": "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.\n\nUse this tool when:\n- The user explicitly asks for root cause analysis, Seer analysis, or help fixing/debugging an issue\n- You are unable to accurately determine the root cause from the issue details alone\n\nDo NOT call this tool as an automatic follow-up to get_sentry_resource.\n\nWhat this tool provides:\n- Root cause analysis with code-level explanations\n- Specific file locations and line numbers where errors occur\n- Concrete code fixes you can apply\n- Step-by-step implementation guidance\n\nThis tool automatically:\n1. Checks if analysis already exists (instant results)\n2. Starts new AI analysis if needed (~2-5 minutes)\n3. Returns complete fix recommendations\n\n\n### User: \"Run Seer on this issue\"\n\n```\nanalyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')\n```\n\n### User: \"Analyze this issue and suggest a fix\"\n\n```\nanalyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')\n```\n\n\n\n- Only use when the user explicitly requests analysis or you cannot determine the root cause from issue details alone\n- Seer Autofix does not support metric alert issues (issueCategory: metric); use get_issue_details and search_events instead\n- If the user provides an issueUrl, extract it and use that parameter alone\n- The analysis includes actual code snippets and fixes, not just error descriptions\n- Results are cached - subsequent calls return instantly\n", + "description": "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.\n\nUse this tool when:\n- The user explicitly asks for root cause analysis, Seer analysis, or help fixing/debugging an issue\n- You are unable to accurately determine the root cause from the issue details alone\n\nDo NOT call this tool as an automatic follow-up to get_sentry_resource.\n\nWhat this tool provides:\n- Root cause analysis with code-level explanations\n- Specific file locations and line numbers where errors occur\n- Concrete code fixes you can apply\n- Step-by-step implementation guidance\n\nThis tool automatically:\n1. Checks if analysis already exists (instant results)\n2. Starts new AI analysis if needed (~2-5 minutes)\n3. Returns complete fix recommendations\n\n\n### User: \"Run Seer on this issue\"\n\n```\nanalyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')\n```\n\n### User: \"Analyze this issue and suggest a fix\"\n\n```\nanalyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')\n```\n\n\n\n- Only use when the user explicitly requests analysis or you cannot determine the root cause from issue details alone\n- Seer Autofix does not support metric alert issues (issueCategory: metric); use get_issue_details and search_metrics or search_traces instead\n- If the user provides an issueUrl, extract it and use that parameter alone\n- The analysis includes actual code snippets and fixes, not just error descriptions\n- Results are cached - subsequent calls return instantly\n", "requiredScopes": [] }, { @@ -268,13 +293,38 @@ "requiredScopes": ["event:read", "project:read"] }, { - "name": "search_events", - "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', dataset='errors', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", + "name": "search_errors", + "description": "Search Sentry error events: exceptions and crashes with stack traces. Use for error counts, statistics, trends, and individual error events.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('how many errors today', 'top error types'), individual events ('latest TypeErrors in checkout'), and time series ('errors per hour last 24h').\n\nNOT for log messages, including error/warning logs (use search_logs), requests or latency (use search_traces), or grouped issue lists (use search_issues).\n\n\nsearch_errors(organizationSlug='my-org', query='how many errors today')\nsearch_errors(organizationSlug='my-org', query='most common error types in production this week')\nsearch_errors(organizationSlug='my-org', query='errors per hour last 24h')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", "requiredScopes": ["event:read"] }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit 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 / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\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', query='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", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit 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 / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='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"] + }, + { + "name": "search_logs", + "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_metrics", + "description": "Search Sentry metrics: counters, gauges, and distributions, as rows or aggregates. Use for metric values, percentiles, totals, and trends.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('p95 http.request.duration by environment'), individual metric rows, and time series ('total tokens per day this week').\n\nNOT for span or request latency recorded on traces (use search_traces).\n\n\nsearch_metrics(organizationSlug='my-org', query='p95 http.request.duration grouped by environment over the last 24 hours')\nsearch_metrics(organizationSlug='my-org', query='total tokens used per day this week')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_profiles", + "description": "Search Sentry profiles: transaction and continuous profile results, profile IDs, and profiled transactions. Use to find profiles to inspect.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nUse get_profile or get_profile_details on a result for flamegraph and hotspot analysis.\n\n\nsearch_profiles(organizationSlug='my-org', query='recent profiles for the /checkout transaction')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_replays", + "description": "Search Sentry session replays: rage clicks, dead clicks, visited pages, errors seen, and replay users.\n\n`query` is natural language (preferred) or replay search syntax; a configured agent translates it into a replay search.\n\nReturns replay lists only; count()/avg()/sum() are not supported. Use get_replay_details on a result for one replay.\n\n\nsearch_replays(organizationSlug='my-org', query='replays with rage clicks on checkout in the last day')\nsearch_replays(organizationSlug='my-org', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_traces", + "description": "Search Sentry spans and traces: requests, API/HTTP calls, endpoints, DB queries, AI/LLM calls, and other operations. Use for latency, throughput, slowness, and performance questions.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nAlso use for spans whose trace contains a matching log, metric, or other span, e.g. 'slow checkout requests that also logged an error'.\n\nSupports aggregations ('p95 duration by span.op'), individual spans ('slowest API calls today'), and time series ('requests per minute last hour').\n\nNOT for exceptions/crashes (use search_errors), standalone log lines (use search_logs), or a single trace's span tree (use get_sentry_resource with the trace).\n\n\nsearch_traces(organizationSlug='my-org', query='slowest API calls in the last 24 hours')\nsearch_traces(organizationSlug='my-org', query='p95 duration of db spans grouped by span.op over 7 days')\nsearch_traces(organizationSlug='my-org', query='checkout requests that also have an error log')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", "requiredScopes": ["event:read"] }, { @@ -331,7 +381,7 @@ "description": "Resolve, assign, and update issues", "defaultEnabled": false, "order": 4, - "toolCount": 20, + "toolCount": 25, "tools": [ { "name": "add_issue_note", @@ -404,8 +454,8 @@ "requiredScopes": ["event:read", "project:read"] }, { - "name": "search_events", - "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', dataset='errors', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", + "name": "search_errors", + "description": "Search Sentry error events: exceptions and crashes with stack traces. Use for error counts, statistics, trends, and individual error events.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('how many errors today', 'top error types'), individual events ('latest TypeErrors in checkout'), and time series ('errors per hour last 24h').\n\nNOT for log messages, including error/warning logs (use search_logs), requests or latency (use search_traces), or grouped issue lists (use search_issues).\n\n\nsearch_errors(organizationSlug='my-org', query='how many errors today')\nsearch_errors(organizationSlug='my-org', query='most common error types in production this week')\nsearch_errors(organizationSlug='my-org', query='errors per hour last 24h')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", "requiredScopes": ["event:read"] }, { @@ -415,7 +465,32 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit 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 / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\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', query='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", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit 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 / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='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"] + }, + { + "name": "search_logs", + "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_metrics", + "description": "Search Sentry metrics: counters, gauges, and distributions, as rows or aggregates. Use for metric values, percentiles, totals, and trends.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('p95 http.request.duration by environment'), individual metric rows, and time series ('total tokens per day this week').\n\nNOT for span or request latency recorded on traces (use search_traces).\n\n\nsearch_metrics(organizationSlug='my-org', query='p95 http.request.duration grouped by environment over the last 24 hours')\nsearch_metrics(organizationSlug='my-org', query='total tokens used per day this week')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_profiles", + "description": "Search Sentry profiles: transaction and continuous profile results, profile IDs, and profiled transactions. Use to find profiles to inspect.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nUse get_profile or get_profile_details on a result for flamegraph and hotspot analysis.\n\n\nsearch_profiles(organizationSlug='my-org', query='recent profiles for the /checkout transaction')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_replays", + "description": "Search Sentry session replays: rage clicks, dead clicks, visited pages, errors seen, and replay users.\n\n`query` is natural language (preferred) or replay search syntax; a configured agent translates it into a replay search.\n\nReturns replay lists only; count()/avg()/sum() are not supported. Use get_replay_details on a result for one replay.\n\n\nsearch_replays(organizationSlug='my-org', query='replays with rage clicks on checkout in the last day')\nsearch_replays(organizationSlug='my-org', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n", + "requiredScopes": ["event:read"] + }, + { + "name": "search_traces", + "description": "Search Sentry spans and traces: requests, API/HTTP calls, endpoints, DB queries, AI/LLM calls, and other operations. Use for latency, throughput, slowness, and performance questions.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nAlso use for spans whose trace contains a matching log, metric, or other span, e.g. 'slow checkout requests that also logged an error'.\n\nSupports aggregations ('p95 duration by span.op'), individual spans ('slowest API calls today'), and time series ('requests per minute last hour').\n\nNOT for exceptions/crashes (use search_errors), standalone log lines (use search_logs), or a single trace's span tree (use get_sentry_resource with the trace).\n\n\nsearch_traces(organizationSlug='my-org', query='slowest API calls in the last 24 hours')\nsearch_traces(organizationSlug='my-org', query='p95 duration of db spans grouped by span.op over 7 days')\nsearch_traces(organizationSlug='my-org', query='checkout requests that also have an error log')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 534c64d3c..cab05bc0e 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -111,7 +111,7 @@ }, { "name": "analyze_issue_with_seer", - "description": "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.\n\nUse this tool when:\n- The user explicitly asks for root cause analysis, Seer analysis, or help fixing/debugging an issue\n- You are unable to accurately determine the root cause from the issue details alone\n\nDo NOT call this tool as an automatic follow-up to get_sentry_resource.\n\nWhat this tool provides:\n- Root cause analysis with code-level explanations\n- Specific file locations and line numbers where errors occur\n- Concrete code fixes you can apply\n- Step-by-step implementation guidance\n\nThis tool automatically:\n1. Checks if analysis already exists (instant results)\n2. Starts new AI analysis if needed (~2-5 minutes)\n3. Returns complete fix recommendations\n\n\n### User: \"Run Seer on this issue\"\n\n```\nanalyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')\n```\n\n### User: \"Analyze this issue and suggest a fix\"\n\n```\nanalyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')\n```\n\n\n\n- Only use when the user explicitly requests analysis or you cannot determine the root cause from issue details alone\n- Seer Autofix does not support metric alert issues (issueCategory: metric); use get_issue_details and search_events instead\n- If the user provides an issueUrl, extract it and use that parameter alone\n- The analysis includes actual code snippets and fixes, not just error descriptions\n- Results are cached - subsequent calls return instantly\n", + "description": "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.\n\nUse this tool when:\n- The user explicitly asks for root cause analysis, Seer analysis, or help fixing/debugging an issue\n- You are unable to accurately determine the root cause from the issue details alone\n\nDo NOT call this tool as an automatic follow-up to get_sentry_resource.\n\nWhat this tool provides:\n- Root cause analysis with code-level explanations\n- Specific file locations and line numbers where errors occur\n- Concrete code fixes you can apply\n- Step-by-step implementation guidance\n\nThis tool automatically:\n1. Checks if analysis already exists (instant results)\n2. Starts new AI analysis if needed (~2-5 minutes)\n3. Returns complete fix recommendations\n\n\n### User: \"Run Seer on this issue\"\n\n```\nanalyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')\n```\n\n### User: \"Analyze this issue and suggest a fix\"\n\n```\nanalyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')\n```\n\n\n\n- Only use when the user explicitly requests analysis or you cannot determine the root cause from issue details alone\n- Seer Autofix does not support metric alert issues (issueCategory: metric); use get_issue_details and search_metrics or search_traces instead\n- If the user provides an issueUrl, extract it and use that parameter alone\n- The analysis includes actual code snippets and fixes, not just error descriptions\n- Results are cached - subsequent calls return instantly\n", "inputSchema": { "type": "object", "properties": { @@ -5181,7 +5181,7 @@ }, { "name": "get_latest_base_snapshot", - "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots — not search_events or search_issues.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", + "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots — not search_replays or search_issues.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", "inputSchema": { "type": "object", "properties": { @@ -6051,7 +6051,7 @@ }, { "name": "get_trace_details", - "description": "Get detailed information about a specific Sentry trace by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')\n- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'\n- Want high-level overview and link to view trace details in Sentry\n- Need trace statistics and span breakdown\n- Want an overview first, then a guided pivot into additional spans or events\n\nDO NOT USE for:\n- General searching for traces (use search_events with trace queries)\n- Complete span enumeration or branch-by-branch reconstruction (use search_events scoped to the trace)\n\nTRIGGER PATTERNS:\n- 'Show me trace abc123' → use get_trace_details\n- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details\n- 'What is trace [trace-id]' → use get_trace_details\n\n\n### Get trace overview\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')\n```\n\n### Focus a single span\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a', spanId='aa8e7f3384ef4ff5')\n```\n\n\n\n- Trace IDs are 32-character hexadecimal strings\n- This returns a condensed trace overview, not a full span dump\n- Provide `spanId` to focus on a single span within the trace\n- If the response says it shows a subset of spans, use search_events to inspect the rest of the trace\n", + "description": "Get detailed information about a specific Sentry trace by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')\n- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'\n- Want high-level overview and link to view trace details in Sentry\n- Need trace statistics and span breakdown\n- Want an overview first, then a guided pivot into additional spans or events\n\nDO NOT USE for:\n- General searching for traces (use search_traces)\n- Complete span enumeration or branch-by-branch reconstruction (use search_traces scoped to the trace)\n\nTRIGGER PATTERNS:\n- 'Show me trace abc123' → use get_trace_details\n- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details\n- 'What is trace [trace-id]' → use get_trace_details\n\n\n### Get trace overview\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')\n```\n\n### Focus a single span\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a', spanId='aa8e7f3384ef4ff5')\n```\n\n\n\n- Trace IDs are 32-character hexadecimal strings\n- This returns a condensed trace overview, not a full span dump\n- Provide `spanId` to focus on a single span within the trace\n- If the response says it shows a subset of spans, use search_traces to inspect the rest of the trace\n", "inputSchema": { "type": "object", "properties": { @@ -7201,9 +7201,96 @@ "skills": ["inspect", "docs"], "surface": "catalog" }, + { + "name": "search_errors", + "description": "Search Sentry error events: exceptions and crashes with stack traces. Use for error counts, statistics, trends, and individual error events.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('how many errors today', 'top error types'), individual events ('latest TypeErrors in checkout'), and time series ('errors per hour last 24h').\n\nNOT for log messages, including error/warning logs (use search_logs), requests or latency (use search_traces), or grouped issue lists (use search_issues).\n\n\nsearch_errors(organizationSlug='my-org', query='how many errors today')\nsearch_errors(organizationSlug='my-org', query='most common error types in production this week')\nsearch_errors(organizationSlug='my-org', query='errors per hour last 24h')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "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": { + "description": "What to find, in natural language (preferred) or Sentry search syntax. Include environment, release, or other filters here.", + "type": "string" + }, + "fields": { + "description": "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "sort": { + "description": "Sort field (prefix with - for descending). Defaults to -timestamp. Use -count() for aggregations.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "default": null, + "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" + } + ] + }, + "period": { + "type": "string", + "pattern": "^\\d+[hdw]$", + "description": "Relative time window, such as `24h`, `7d`, `14d`, `30d`, or `90d`. Controls which records fall within the search window." + }, + "regionUrl": { + "default": null, + "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" + } + ] + }, + "limit": { + "default": 10, + "description": "Maximum number of results to return (1-100)", + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "includeExplanation": { + "default": false, + "description": "Include explanation of how the query was translated or repaired", + "type": "boolean" + } + }, + "required": ["organizationSlug"] + }, + "requiredScopes": ["event:read"], + "skills": ["inspect", "triage", "seer"], + "surface": "direct" + }, { "name": "search_events", - "description": "Search Sentry events and replays. Use for event counts/statistics.\n\n`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.\n\nSupports THREE query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'\n2. Individual events with timestamps: 'error logs from last hour'\n3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'\n\nDatasets:\n- errors: Exception/crash events with stack traces, usually grouped into issues\n- logs: Application log entries, including error-severity log messages\n- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations\n- metrics: Metric rows and aggregates: counters, gauges, distributions, values\n- profiles: Transaction/continuous profile results, profile IDs, profiled transactions\n- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users\nIf the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.\n\nReplay searches return replay lists only; replay count()/avg()/sum() are not supported.\n\nNOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).\n\n\nsearch_events(organizationSlug='my-org', dataset='errors', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')\nsearch_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')\nsearch_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Use fields with aggregate functions like count(), avg(), sum() for statistics\n- Sort by -count() for most common, -timestamp for newest\n", + "description": "Deprecated multi-dataset event search, kept for backward compatibility.\n\nUse search_errors, search_logs, search_traces, search_metrics, search_profiles, or search_replays for new integrations.", "inputSchema": { "type": "object", "properties": { @@ -7308,7 +7395,7 @@ }, "requiredScopes": ["event:read"], "skills": ["inspect", "triage", "seer"], - "surface": "direct" + "surface": "catalog" }, { "name": "search_issue_events", @@ -7397,7 +7484,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit 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 / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\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', query='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", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit 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 / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='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": { @@ -7465,6 +7552,357 @@ "skills": ["inspect", "triage", "seer"], "surface": "direct" }, + { + "name": "search_logs", + "description": "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').\n\nNOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.\n\n\nsearch_logs(organizationSlug='my-org', query='error logs from the last hour')\nsearch_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')\nsearch_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "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": { + "description": "What to find, in natural language (preferred) or Sentry search syntax. Include environment, release, or other filters here.", + "type": "string" + }, + "fields": { + "description": "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "sort": { + "description": "Sort field (prefix with - for descending). Defaults to -timestamp. Use -count() for aggregations.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "default": null, + "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" + } + ] + }, + "period": { + "type": "string", + "pattern": "^\\d+[hdw]$", + "description": "Relative time window, such as `24h`, `7d`, `14d`, `30d`, or `90d`. Controls which records fall within the search window." + }, + "regionUrl": { + "default": null, + "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" + } + ] + }, + "limit": { + "default": 10, + "description": "Maximum number of results to return (1-100)", + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "includeExplanation": { + "default": false, + "description": "Include explanation of how the query was translated or repaired", + "type": "boolean" + } + }, + "required": ["organizationSlug"] + }, + "requiredScopes": ["event:read"], + "skills": ["inspect", "triage", "seer"], + "surface": "direct" + }, + { + "name": "search_metrics", + "description": "Search Sentry metrics: counters, gauges, and distributions, as rows or aggregates. Use for metric values, percentiles, totals, and trends.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nSupports aggregations ('p95 http.request.duration by environment'), individual metric rows, and time series ('total tokens per day this week').\n\nNOT for span or request latency recorded on traces (use search_traces).\n\n\nsearch_metrics(organizationSlug='my-org', query='p95 http.request.duration grouped by environment over the last 24 hours')\nsearch_metrics(organizationSlug='my-org', query='total tokens used per day this week')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "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": { + "description": "What to find, in natural language (preferred) or Sentry search syntax. Include environment, release, or other filters here.", + "type": "string" + }, + "fields": { + "description": "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "sort": { + "description": "Sort field (prefix with - for descending). Defaults to -timestamp. Use -count() for aggregations.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "default": null, + "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" + } + ] + }, + "period": { + "type": "string", + "pattern": "^\\d+[hdw]$", + "description": "Relative time window, such as `24h`, `7d`, `14d`, `30d`, or `90d`. Controls which records fall within the search window." + }, + "regionUrl": { + "default": null, + "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" + } + ] + }, + "limit": { + "default": 10, + "description": "Maximum number of results to return (1-100)", + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "includeExplanation": { + "default": false, + "description": "Include explanation of how the query was translated or repaired", + "type": "boolean" + } + }, + "required": ["organizationSlug"] + }, + "requiredScopes": ["event:read"], + "skills": ["inspect", "triage", "seer"], + "surface": "direct" + }, + { + "name": "search_profiles", + "description": "Search Sentry profiles: transaction and continuous profile results, profile IDs, and profiled transactions. Use to find profiles to inspect.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nUse get_profile or get_profile_details on a result for flamegraph and hotspot analysis.\n\n\nsearch_profiles(organizationSlug='my-org', query='recent profiles for the /checkout transaction')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "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": { + "description": "What to find, in natural language (preferred) or Sentry search syntax. Include environment, release, or other filters here.", + "type": "string" + }, + "fields": { + "description": "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "sort": { + "description": "Sort field (prefix with - for descending). Defaults to -timestamp. Use -count() for aggregations.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "default": null, + "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" + } + ] + }, + "period": { + "type": "string", + "pattern": "^\\d+[hdw]$", + "description": "Relative time window, such as `24h`, `7d`, `14d`, `30d`, or `90d`. Controls which records fall within the search window." + }, + "regionUrl": { + "default": null, + "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" + } + ] + }, + "limit": { + "default": 10, + "description": "Maximum number of results to return (1-100)", + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "includeExplanation": { + "default": false, + "description": "Include explanation of how the query was translated or repaired", + "type": "boolean" + } + }, + "required": ["organizationSlug"] + }, + "requiredScopes": ["event:read"], + "skills": ["inspect", "triage", "seer"], + "surface": "direct" + }, + { + "name": "search_replays", + "description": "Search Sentry session replays: rage clicks, dead clicks, visited pages, errors seen, and replay users.\n\n`query` is natural language (preferred) or replay search syntax; a configured agent translates it into a replay search.\n\nReturns replay lists only; count()/avg()/sum() are not supported. Use get_replay_details on a result for one replay.\n\n\nsearch_replays(organizationSlug='my-org', query='replays with rage clicks on checkout in the last day')\nsearch_replays(organizationSlug='my-org', query='count_errors:>0', sort='-count_errors')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n", + "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": { + "description": "What to find, in natural language (preferred) or replay search syntax.", + "type": "string" + }, + "sort": { + "description": "Replay sort (prefix with - for descending): -started_at (default), -count_errors, -count_rage_clicks, or -duration.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "default": null, + "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" + } + ] + }, + "environment": { + "description": "Optional environment filter. Use a string for one environment or an array for multiple. Omit when unused.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "period": { + "type": "string", + "pattern": "^\\d+[hdw]$", + "description": "Relative time window, such as `24h`, `7d`, `14d`, `30d`, or `90d`. Controls which records fall within the search window." + }, + "regionUrl": { + "default": null, + "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" + } + ] + }, + "limit": { + "default": 10, + "description": "Maximum number of results to return (1-100)", + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "includeExplanation": { + "default": false, + "description": "Include explanation of how the query was translated or repaired", + "type": "boolean" + } + }, + "required": ["organizationSlug"] + }, + "requiredScopes": ["event:read"], + "skills": ["inspect", "triage", "seer"], + "surface": "direct" + }, { "name": "search_sentry_tools", "description": "Search the available Sentry MCP tool catalog by name and description.\n\nMany Sentry operations are intentionally not exposed as top-level tools. Use this for any Sentry-related task when you do not see an obvious direct tool, including long-tail inspection, project management, documentation lookup, preprod snapshots, attachments, DSNs, releases, teams, and issue-specific pivots.\n\nUse this tool when you need to:\n- Find the right Sentry operation for a task\n- Discover catalog tools and their schemas for a task\n- Inspect the executable JSON input schema for an available tool\n\n\nsearch_sentry_tools(query='list projects')\nsearch_sentry_tools(query='issue details')\nsearch_sentry_tools(query='find dsn', limit=5)\nsearch_sentry_tools(query='snapshot image')\n\n\n\n- Results only include tools available in the current session.\n- If a Sentry operation is not listed as a direct tool, search here before deciding it is unavailable.\n- Returned schemas already account for active organization, project, and region constraints.\n- Use the returned name and schema when executing a catalog result.\n- This tool returns structured JSON. Do not parse markdown from its text content.\n", @@ -7549,6 +7987,93 @@ "skills": ["inspect", "seer", "docs", "triage", "project-management"], "surface": "direct" }, + { + "name": "search_traces", + "description": "Search Sentry spans and traces: requests, API/HTTP calls, endpoints, DB queries, AI/LLM calls, and other operations. Use for latency, throughput, slowness, and performance questions.\n\n`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.\n\nAlso use for spans whose trace contains a matching log, metric, or other span, e.g. 'slow checkout requests that also logged an error'.\n\nSupports aggregations ('p95 duration by span.op'), individual spans ('slowest API calls today'), and time series ('requests per minute last hour').\n\nNOT for exceptions/crashes (use search_errors), standalone log lines (use search_logs), or a single trace's span tree (use get_sentry_resource with the trace).\n\n\nsearch_traces(organizationSlug='my-org', query='slowest API calls in the last 24 hours')\nsearch_traces(organizationSlug='my-org', query='p95 duration of db spans grouped by span.op over 7 days')\nsearch_traces(organizationSlug='my-org', query='checkout requests that also have an error log')\n\n\n\n- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.\n- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.\n", + "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": { + "description": "What to find, in natural language (preferred) or Sentry search syntax. Include environment, release, or other filters here.", + "type": "string" + }, + "fields": { + "description": "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "sort": { + "description": "Sort field (prefix with - for descending). Defaults to -timestamp. Use -count() for aggregations.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectSlug": { + "default": null, + "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" + } + ] + }, + "period": { + "type": "string", + "pattern": "^\\d+[hdw]$", + "description": "Relative time window, such as `24h`, `7d`, `14d`, `30d`, or `90d`. Controls which records fall within the search window." + }, + "regionUrl": { + "default": null, + "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" + } + ] + }, + "limit": { + "default": 10, + "description": "Maximum number of results to return (1-100)", + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "includeExplanation": { + "default": false, + "description": "Include explanation of how the query was translated or repaired", + "type": "boolean" + } + }, + "required": ["organizationSlug"] + }, + "requiredScopes": ["event:read"], + "skills": ["inspect", "triage", "seer"], + "surface": "direct" + }, { "name": "unlink_issue", "description": "Remove an external ticket or GitHub pull request reference from a Sentry issue by URL.\nRemoves only the Sentry association. It does not delete the external ticket or the Sentry issue, or change resolution status.\nSupports native integrations and installed Sentry Apps. Repeating the request is safe: not_linked means the association is absent, whether removed by this call or already absent.\n\nunlink_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/example/repo/issues/42')\n", diff --git a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts index 3c99e606e..3803f0ef3 100644 --- a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts +++ b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts @@ -497,7 +497,7 @@ describe("analyze_issue_with_seer", () => { expect(autofixRequests).toBe(0); expect(result).toContain("Seer Analysis Not Available"); expect(result).toContain("metric"); - expect(result).toContain("search_events"); + expect(result).toContain("search_metrics"); }); it("rejects issues outside the active project constraint", async () => { diff --git a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts index ad35d07a5..cfa625159 100644 --- a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts +++ b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts @@ -70,7 +70,7 @@ export default defineTool({ "", "", "- Only use when the user explicitly requests analysis or you cannot determine the root cause from issue details alone", - "- Seer Autofix does not support metric alert issues (issueCategory: metric); use get_issue_details and search_events instead", + "- Seer Autofix does not support metric alert issues (issueCategory: metric); use get_issue_details and search_metrics or search_traces instead", "- If the user provides an issueUrl, extract it and use that parameter alone", "- The analysis includes actual code snippets and fixes, not just error descriptions", "- Results are cached - subsequent calls return instantly", diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts index 407f1e34f..c6beff31c 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts @@ -303,8 +303,8 @@ describe("get_issue_details", () => { - The stacktrace includes first-party application code and third-party code. First-party frames are usually the best starting point for triage. - Issue event search: Use the Sentry tool \`search_issue_events\` - Full distributed trace and span tree: Use the Sentry tool \`get_sentry_resource\` - - Related span search: Use the Sentry tool \`search_events\` - - Related log search: Use the Sentry tool \`search_events\` + - Related span search: Use the Sentry tool \`search_traces\` + - Related log search: Use the Sentry tool \`search_logs\` " `); }); @@ -696,8 +696,8 @@ describe("get_issue_details", () => { - Use the Sentry tool \`execute_sentry_tool(name='get_agent_conversation_details', arguments={"organizationSlug":"sentry-mcp-evals","conversationId":"conv-123"})\` to fetch the full transcript. - Issue event search: Use the Sentry tool \`search_issue_events\` - Full distributed trace and span tree: Use the Sentry tool \`get_sentry_resource\` - - Related span search: Use the Sentry tool \`search_events\` - - Related log search: Use the Sentry tool \`search_events\` + - Related span search: Use the Sentry tool \`search_traces\` + - Related log search: Use the Sentry tool \`search_logs\` " `); }); @@ -1165,8 +1165,8 @@ describe("get_issue_details", () => { - The stacktrace includes first-party application code and third-party code. First-party frames are usually the best starting point for triage. - Issue event search: Use the Sentry tool \`search_issue_events\` - Full distributed trace and span tree: Use the Sentry tool \`get_sentry_resource\` - - Related span search: Use the Sentry tool \`search_events\` - - Related log search: Use the Sentry tool \`search_events\` + - Related span search: Use the Sentry tool \`search_traces\` + - Related log search: Use the Sentry tool \`search_logs\` " `); }); @@ -1450,8 +1450,8 @@ describe("get_issue_details", () => { - The stacktrace includes first-party application code and third-party code. First-party frames are usually the best starting point for triage. - Issue event search: Use the Sentry tool \`search_issue_events\` - Full distributed trace and span tree: Use the Sentry tool \`get_sentry_resource\` - - Related span search: Use the Sentry tool \`search_events\` - - Related log search: Use the Sentry tool \`search_events\` + - Related span search: Use the Sentry tool \`search_traces\` + - Related log search: Use the Sentry tool \`search_logs\` " `); }); diff --git a/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts b/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts index 936089557..8c8a884b1 100644 --- a/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts +++ b/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts @@ -15,7 +15,7 @@ export default defineTool({ description: [ "Get the latest UI screenshots/images for an app from the preprod snapshot system.", "", - "This is the primary tool for retrieving app screenshots — not search_events or search_issues.", + "This is the primary tool for retrieving app screenshots — not search_replays or search_issues.", "", "Use this tool when you need to:", "- Get screenshots, screens, golden images, or reference images for an app", diff --git a/packages/mcp-core/src/tools/catalog/get-profile-details.test.ts b/packages/mcp-core/src/tools/catalog/get-profile-details.test.ts index 3fea5ff20..cdb2ee426 100644 --- a/packages/mcp-core/src/tools/catalog/get-profile-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-profile-details.test.ts @@ -30,7 +30,7 @@ describe("get_profile_details", () => { expect(result).toMatchInlineSnapshot(` "# Profile cfe78a5c892d4a64a962d837673398d2 - + ## Summary - **Profile URL**: https://sentry-mcp-evals.sentry.io/explore/profiling/profile/backend/cfe78a5c892d4a64a962d837673398d2/flamegraph/ - **Project**: backend @@ -46,29 +46,29 @@ describe("get_profile_details", () => { - **OS**: macOS 14.4 - **SDK**: sentry.python 2.24.1 - **Active Thread**: 1 - + ## Sample Summary - **Total Frames**: 3 - **Total Samples**: 3 - **Total Stacks**: 2 - **Threads**: 1 - + ## Thread Information - + - **Thread 1**: MainThread (3 samples) - + ## Top Frames by Occurrence - + | Function | File:Line | Count | Type | |----------|-----------|-------|------| | \`handle_request\` | main.py:42 | 3 | User Code | | \`execute_query\` | db.py:118 | 2 | User Code | - + ## Next Steps - + - Open the profile URL above in Sentry for the full flamegraph - Open the related trace URL to inspect the end-to-end request - - Use \`search_events\` with the profiles dataset to find similar profiles" + - Use \`search_profiles\` to find similar profiles" `); }); diff --git a/packages/mcp-core/src/tools/catalog/get-profile.test.ts b/packages/mcp-core/src/tools/catalog/get-profile.test.ts index 5ea67b1de..4dc29453f 100644 --- a/packages/mcp-core/src/tools/catalog/get-profile.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-profile.test.ts @@ -142,7 +142,7 @@ describe("get_profile", () => { - Transaction may not have been executed recently **Suggestions:** - - Verify the exact transaction name using search_events + - Verify the exact transaction name using search_traces - Try a longer time period (e.g., '30d') - Check if profiling is enabled for this project" `); @@ -286,7 +286,7 @@ describe("get_profile", () => { - Profiling may not be enabled for this project **Suggestions:** - - Verify the exact transaction name using search_events + - Verify the exact transaction name using search_traces - Check if profiling is enabled for this project" `); }); diff --git a/packages/mcp-core/src/tools/catalog/get-profile.ts b/packages/mcp-core/src/tools/catalog/get-profile.ts index 41b0eb083..b287a33e8 100644 --- a/packages/mcp-core/src/tools/catalog/get-profile.ts +++ b/packages/mcp-core/src/tools/catalog/get-profile.ts @@ -282,7 +282,7 @@ export default defineTool({ "- Profiling may not be enabled for this project", "", "**Suggestions:**", - "- Verify the exact transaction name using search_events", + "- Verify the exact transaction name using search_traces", "- Check if profiling is enabled for this project", ].join("\n"); } @@ -343,7 +343,7 @@ export default defineTool({ "- Transaction may not have been executed recently", "", "**Suggestions:**", - "- Verify the exact transaction name using search_events", + "- Verify the exact transaction name using search_traces", "- Try a longer time period (e.g., '30d')", "- Check if profiling is enabled for this project", ].join("\n"); diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts index 0151c8b82..de0c8344c 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts @@ -196,7 +196,7 @@ function resolveFromParsedUrl( if (detectedType === "unknown") { if (parsed.transaction) { throw new UserInputError( - `Detected a performance summary URL for transaction "${parsed.transaction}". Use \`search_events\` to find traces and performance data for this transaction.`, + `Detected a performance summary URL for transaction "${parsed.transaction}". Use \`search_traces\` to find traces and performance data for this transaction.`, ); } throw new UserInputError( diff --git a/packages/mcp-core/src/tools/catalog/get-span-details.test.ts b/packages/mcp-core/src/tools/catalog/get-span-details.test.ts index e9c312c1b..a7132059b 100644 --- a/packages/mcp-core/src/tools/catalog/get-span-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-span-details.test.ts @@ -163,9 +163,9 @@ describe("get_span_details", () => { ## Next Steps - - **Search spans**: Use the Sentry tool \`search_events\` - - **Search errors**: Use the Sentry tool \`search_events\` - - **Search logs**: Use the Sentry tool \`search_events\`" + - **Search spans**: Use the Sentry tool \`search_traces\` + - **Search errors**: Use the Sentry tool \`search_errors\` + - **Search logs**: Use the Sentry tool \`search_logs\`" `); }); }); diff --git a/packages/mcp-core/src/tools/catalog/get-trace-details.test.ts b/packages/mcp-core/src/tools/catalog/get-trace-details.test.ts index 2f8de0356..72e6fdcb8 100644 --- a/packages/mcp-core/src/tools/catalog/get-trace-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-trace-details.test.ts @@ -218,9 +218,9 @@ describe("get_trace_details", () => { ## Next Steps - - **Search spans**: Use the Sentry tool \`search_events\` - - **Search errors**: Use the Sentry tool \`search_events\` - - **Search logs**: Use the Sentry tool \`search_events\`" + - **Search spans**: Use the Sentry tool \`search_traces\` + - **Search errors**: Use the Sentry tool \`search_errors\` + - **Search logs**: Use the Sentry tool \`search_logs\`" `); }); @@ -244,11 +244,11 @@ describe("get_trace_details", () => { ); expect(result).toContain("**Total Spans**: 112"); expect(result).toContain( - "**Search spans**: Use the Sentry tool `search_events`", + "**Search spans**: Use the Sentry tool `search_traces`", ); }); - it("falls back to direct search_events guidance when agent search is unavailable", async () => { + it("falls back to direct search_traces guidance when agent search is unavailable", async () => { Reflect.deleteProperty(process.env, "OPENAI_API_KEY"); Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY"); Reflect.deleteProperty(process.env, "OPENROUTER_API_KEY"); @@ -270,11 +270,11 @@ describe("get_trace_details", () => { ); expect(result).toContain( - "**Search spans**: Use the Sentry tool `search_events`", + "**Search spans**: Use the Sentry tool `search_traces`", ); }); - it("does not show trace next-step tool calls when search_events is unavailable", async () => { + it("does not show trace next-step tool calls when search tools are unavailable", async () => { Reflect.deleteProperty(process.env, "OPENAI_API_KEY"); Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY"); Reflect.deleteProperty(process.env, "OPENROUTER_API_KEY"); @@ -303,6 +303,7 @@ describe("get_trace_details", () => { "**Search errors**: Error search is not available", ); expect(result).toContain("**Search logs**: Log search is not available"); + expect(result).not.toContain("search_traces("); expect(result).not.toContain("search_events("); }); @@ -646,9 +647,9 @@ describe("get_trace_details", () => { ## Next Steps - - **Search spans**: Use the Sentry tool \`search_events\` - - **Search errors**: Use the Sentry tool \`search_events\` - - **Search logs**: Use the Sentry tool \`search_events\`" + - **Search spans**: Use the Sentry tool \`search_traces\` + - **Search errors**: Use the Sentry tool \`search_errors\` + - **Search logs**: Use the Sentry tool \`search_logs\`" `); }); @@ -738,9 +739,9 @@ describe("get_trace_details", () => { ## Next Steps - - **Search spans**: Use the Sentry tool \`search_events\` - - **Search errors**: Use the Sentry tool \`search_events\` - - **Search logs**: Use the Sentry tool \`search_events\`" + - **Search spans**: Use the Sentry tool \`search_traces\` + - **Search errors**: Use the Sentry tool \`search_errors\` + - **Search logs**: Use the Sentry tool \`search_logs\`" `); }); @@ -1469,9 +1470,9 @@ describe("get_trace_details", () => { ## Next Steps - - **Search spans**: Use the Sentry tool \`search_events\` - - **Search errors**: Use the Sentry tool \`search_events\` - - **Search logs**: Use the Sentry tool \`search_events\`" + - **Search spans**: Use the Sentry tool \`search_traces\` + - **Search errors**: Use the Sentry tool \`search_errors\` + - **Search logs**: Use the Sentry tool \`search_logs\`" `); }); }); diff --git a/packages/mcp-core/src/tools/catalog/get-trace-details.ts b/packages/mcp-core/src/tools/catalog/get-trace-details.ts index 02b41d977..c1debf7c1 100644 --- a/packages/mcp-core/src/tools/catalog/get-trace-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-trace-details.ts @@ -75,8 +75,8 @@ export default defineTool({ "- Want an overview first, then a guided pivot into additional spans or events", "", "DO NOT USE for:", - "- General searching for traces (use search_events with trace queries)", - "- Complete span enumeration or branch-by-branch reconstruction (use search_events scoped to the trace)", + "- General searching for traces (use search_traces)", + "- Complete span enumeration or branch-by-branch reconstruction (use search_traces scoped to the trace)", "", "TRIGGER PATTERNS:", "- 'Show me trace abc123' → use get_trace_details", @@ -99,7 +99,7 @@ export default defineTool({ "- Trace IDs are 32-character hexadecimal strings", "- This returns a condensed trace overview, not a full span dump", "- Provide `spanId` to focus on a single span within the trace", - "- If the response says it shows a subset of spans, use search_events to inspect the rest of the trace", + "- If the response says it shows a subset of spans, use search_traces to inspect the rest of the trace", "", ].join("\n"), inputSchema: { @@ -1248,15 +1248,17 @@ function buildTraceNextSteps({ }): string[] { const formatSearchStep = ({ label, + toolName, arguments: args, fallbackInstruction, }: { label: string; + toolName: "search_traces" | "search_errors" | "search_logs"; arguments: Record; fallbackInstruction: string; }) => `- **${label}**: ${formatToolCallInstruction({ - toolName: "search_events", + toolName, arguments: { organizationSlug, ...args, @@ -1275,6 +1277,7 @@ function buildTraceNextSteps({ return [ formatSearchStep({ label: "Search spans", + toolName: "search_traces", arguments: { query: spanQuery, }, @@ -1282,6 +1285,7 @@ function buildTraceNextSteps({ }), formatSearchStep({ label: "Search errors", + toolName: "search_errors", arguments: { query: `show error events from trace ${traceId}`, }, @@ -1289,6 +1293,7 @@ function buildTraceNextSteps({ }), formatSearchStep({ label: "Search logs", + toolName: "search_logs", arguments: { query: `show logs from trace ${traceId}`, }, @@ -1300,24 +1305,24 @@ function buildTraceNextSteps({ return [ formatSearchStep({ label: "Search spans", + toolName: "search_traces", arguments: { - dataset: "spans", query: `trace:${traceId}`, }, fallbackInstruction: "Span search is not available in this session", }), formatSearchStep({ label: "Search errors", + toolName: "search_errors", arguments: { - dataset: "errors", query: `trace:${traceId}`, }, fallbackInstruction: "Error search is not available in this session", }), formatSearchStep({ label: "Search logs", + toolName: "search_logs", arguments: { - dataset: "logs", query: `trace:${traceId}`, }, fallbackInstruction: "Log search is not available in this session", diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index 895e7f708..dbe714170 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -39,6 +39,12 @@ import updateIssue from "./update-issue"; import linkIssue from "./link-issue"; import unlinkIssue from "./unlink-issue"; import searchEvents from "./search-events"; +import searchErrors from "./search-errors"; +import searchLogs from "./search-logs"; +import searchTraces from "./search-traces"; +import searchMetrics from "./search-metrics"; +import searchProfiles from "./search-profiles"; +import searchReplays from "./search-replays"; import createTeam from "./create-team"; import createProject from "./create-project"; import updateProject from "./update-project"; @@ -135,6 +141,12 @@ const catalogTools = { update_issue: updateIssue, link_issue: linkIssue, unlink_issue: unlinkIssue, + search_errors: searchErrors, + search_logs: searchLogs, + search_traces: searchTraces, + search_metrics: searchMetrics, + search_profiles: searchProfiles, + search_replays: searchReplays, search_events: searchEvents, create_team: createTeam, create_project: createProject, diff --git a/packages/mcp-core/src/tools/catalog/search-errors.test.ts b/packages/mcp-core/src/tools/catalog/search-errors.test.ts new file mode 100644 index 000000000..891a10894 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-errors.test.ts @@ -0,0 +1,131 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { generateText } from "ai"; +import { HttpResponse, http } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import searchErrors from "./search-errors"; + +vi.mock("@ai-sdk/openai", () => { + const mockModel = vi.fn(() => "mocked-model"); + return { + openai: mockModel, + createOpenAI: vi.fn(() => mockModel), + }; +}); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: vi.fn(), + tool: vi.fn(() => ({ execute: vi.fn() })), + Output: { object: vi.fn(() => ({})) }, + }; +}); + +const context = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", +}; + +function agentResponse(output: Record) { + return { + text: JSON.stringify(output), + experimental_output: output, + finishReason: "stop" as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + warnings: [] as const, + } as any; +} + +describe("search_errors", () => { + const mockGenerateText = vi.mocked(generateText); + + beforeEach(() => { + vi.clearAllMocks(); + process.env.OPENAI_API_KEY = "test-key"; + process.env.OPENROUTER_API_KEY = ""; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/environments/", + () => HttpResponse.json([]), + ), + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", + () => + HttpResponse.json({ + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: true, error: null, fields: [] }, + orderby: [], + }), + ), + ); + }); + + it("takes its dataset from the tool instead of a parameter", () => { + expect(Object.keys(searchErrors.inputSchema)).toMatchInlineSnapshot(` + [ + "organizationSlug", + "query", + "fields", + "sort", + "projectSlug", + "period", + "regionUrl", + "limit", + "includeExplanation", + ] + `); + }); + + it("keeps the errors dataset when the agent suggests another", async () => { + mockGenerateText.mockResolvedValue( + agentResponse({ + dataset: "logs", + query: "level:error", + fields: ["timestamp", "message"], + sort: "-timestamp", + environment: null, + timeRange: { statsPeriod: "24h" }, + explanation: "Test query translation", + }), + ); + const requestedDatasets: Array = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + requestedDatasets.push( + new URL(request.url).searchParams.get("dataset"), + ); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchErrors.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "how many errors today", + limit: 10, + includeExplanation: false, + }, + context, + ); + + expect(requestedDatasets).toEqual(["errors"]); + expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( + "The dataset is fixed to errors", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/search-errors.ts b/packages/mcp-core/src/tools/catalog/search-errors.ts new file mode 100644 index 000000000..521de65af --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-errors.ts @@ -0,0 +1,38 @@ +import { defineTool } from "../../internal/tool-helpers/define"; +import type { ServerContext } from "../../types"; +import { + buildDatasetSearchInputSchema, + runSearchEvents, + searchToolBase, +} from "../support/search-events/search"; + +export default defineTool({ + ...searchToolBase("search_errors"), + name: "search_errors", + description: [ + "Search Sentry error events: exceptions and crashes with stack traces. Use for error counts, statistics, trends, and individual error events.", + "", + "`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.", + "", + "Supports aggregations ('how many errors today', 'top error types'), individual events ('latest TypeErrors in checkout'), and time series ('errors per hour last 24h').", + "", + "NOT for log messages, including error/warning logs (use search_logs), requests or latency (use search_traces), or grouped issue lists (use search_issues).", + "", + "", + "search_errors(organizationSlug='my-org', query='how many errors today')", + "search_errors(organizationSlug='my-org', query='most common error types in production this week')", + "search_errors(organizationSlug='my-org', query='errors per hour last 24h')", + "", + "", + "", + "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", + "- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.", + "", + ].join("\n"), + inputSchema: buildDatasetSearchInputSchema(), + async handler(params, context: ServerContext) { + return runSearchEvents({ ...params, dataset: "errors" }, context, { + lockDataset: true, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts b/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts index 6a9b6ed1a..b8b4a05a1 100644 --- a/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events-environment-note.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { collectRequestedEnvironments, formatUnknownEnvironmentNote, -} from "./search-events"; +} from "../support/search-events/search"; describe("collectRequestedEnvironments", () => { it("collects from the separate environment field (string and array)", () => { diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 180c53d84..293c8b027 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -1,1019 +1,26 @@ -import { getActiveSpan, setTag } from "@sentry/core"; -import { z } from "zod"; -import { setOrganizationContext } from "../../telem/organization"; -import { UserInputError } from "../../errors"; -import { hasAgentProvider } from "../../internal/agents/provider-factory"; -import { withProviderFallback } from "../../internal/agents/provider-fallback"; -import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import { defineTool } from "../../internal/tool-helpers/define"; -import { - ParamOrganizationSlug, - ParamPeriod, - ParamProjectSlug, - ParamRegionUrl, -} from "../../schema"; -import { logWarn } from "../../telem/logging"; -import { scrubSensitiveText } from "../../telem/sentry"; import type { ServerContext } from "../../types"; import { - isMetricsDataset, - normalizeEventsDataset, - PUBLIC_EVENTS_DATASETS, - type PublicEventsDataset, -} from "../../utils/events-datasets"; -import { extractConversationIdFromSearchQuery } from "../../utils/url-utils"; -import { - fetchEnvironmentNames, - searchEventsAgent, - type searchEventsAgentOutputSchema, -} from "../support/search-events/agent"; -import { - RECOMMENDED_FIELDS, - TRACE_METRICS_SAMPLE_IDENTITY_FIELDS, -} from "../support/search-events/config"; -import { - formatErrorResults, - formatLogResults, - formatProfileResults, - formatSpanResults, - formatTimeSeriesResults, - formatTraceMetricsResults, -} from "../support/search-events/formatters"; -import { - DEFAULT_REPLAY_SORT, - DEFAULT_REPLAY_STATS_PERIOD, - formatReplayResults, - isValidReplaySort, -} from "../support/search-events/replays"; -import { - isSeerSearchDataset, - translateWithSeer, -} from "../support/search-events/seer"; -import { - formatEventsValidationResults, - isAggregateQuery, - isSemanticFilterDowngrade, - looksLikeSentrySearchSyntax, - recordEventsSearchValidationTelemetry, - validateEventsSearch, -} from "../support/search-events/utils"; - -const SEARCH_EVENTS_DATASETS = [...PUBLIC_EVENTS_DATASETS, "replays"] as const; -const DEFAULT_EVENTS_SORT = "-timestamp"; - -type SearchEventsAgentResult = z.output; - -function defaultSortForDataset(dataset: PublicEventsDataset | "replays") { - return dataset === "replays" ? DEFAULT_REPLAY_SORT : DEFAULT_EVENTS_SORT; -} - -function defaultFieldsForDataset(dataset: PublicEventsDataset): string[] { - return RECOMMENDED_FIELDS[normalizeEventsDataset(dataset)].basic; -} - -function resolveEventFields({ - dataset, - explicitFields, - agentFields, - trustExplicitFields, -}: { - dataset: PublicEventsDataset; - explicitFields?: string[] | null; - agentFields?: string[]; - trustExplicitFields: boolean; -}): string[] { - if (trustExplicitFields && explicitFields && explicitFields.length > 0) { - return explicitFields; - } - if (agentFields && agentFields.length > 0) { - return agentFields; - } - return defaultFieldsForDataset(dataset); -} - -function parseAgentTimeRange( - timeRange: unknown, -): { statsPeriod?: string; start?: string; end?: string } | undefined { - if (typeof timeRange !== "object" || timeRange === null) { - return undefined; - } - - if ("statsPeriod" in timeRange && typeof timeRange.statsPeriod === "string") { - return { statsPeriod: timeRange.statsPeriod }; - } - if ( - "start" in timeRange && - "end" in timeRange && - typeof timeRange.start === "string" && - typeof timeRange.end === "string" - ) { - return { start: timeRange.start, end: timeRange.end }; - } - - return undefined; -} - -function augmentFieldsWithSort(fields: string[], sort: string): string[] { - const sortField = sort.startsWith("-") ? sort.slice(1) : sort; - const sortIsAggregate = sortField.includes("(") && sortField.includes(")"); - if ( - sortField && - !fields.includes(sortField) && - (sortIsAggregate || !isAggregateQuery(fields)) - ) { - return [...fields, sortField]; - } - return fields; -} - -function buildRequestFields( - dataset: PublicEventsDataset | "replays", - fields: string[], -): string[] { - return dataset !== "replays" && - isMetricsDataset(dataset) && - !isAggregateQuery(fields) - ? Array.from(new Set([...fields, ...TRACE_METRICS_SAMPLE_IDENTITY_FIELDS])) - : fields; -} - -function isTraceItemDataset(dataset: PublicEventsDataset | "replays"): boolean { - return dataset === "spans" || dataset === "logs" || dataset === "metrics"; -} - -function hasFields(fields?: string[] | null): fields is string[] { - return Array.isArray(fields) && fields.length > 0; -} - -function formatSearchValue(value: string): string { - return /^[^\s"',[\]]+$/.test(value) ? value : JSON.stringify(value); -} - -function formatEnvironmentFilter( - environment?: string | string[] | null, -): string | undefined { - if (!environment) { - return undefined; - } - - const environments = Array.isArray(environment) ? environment : [environment]; - if (environments.length === 0) { - return undefined; - } - if (environments.length === 1) { - const environmentValue = environments[0]; - return environmentValue === undefined - ? undefined - : `environment:${formatSearchValue(environmentValue)}`; - } - return `environment:[${environments.map(formatSearchValue).join(",")}]`; -} - -function appendSearchFilter(query: string, filter?: string): string { - const trimmedQuery = query.trim(); - if (!filter) { - return trimmedQuery; - } - if (tokenizeSearchQuery(trimmedQuery).includes(filter)) { - return trimmedQuery; - } - return [trimmedQuery, filter].filter(Boolean).join(" "); -} + buildSearchEventsInputSchema, + runSearchEvents, + searchToolBase, +} from "../support/search-events/search"; /** - * Collect the environment names a search actually filters on — from both the - * separate `environment` field and any `environment:` token in the query string. - * Sentry only validates the former against real environments, so a bad value in - * the query (e.g. a typo) otherwise slips through and silently returns nothing. + * Legacy multi-dataset search. Kept in the catalog for backward compatibility; + * new callers use the dataset-specific search_* tools. */ -export function collectRequestedEnvironments( - environment: string | string[] | null | undefined, - query: string, -): string[] { - const values: string[] = []; - if (typeof environment === "string") { - values.push(environment); - } else if (Array.isArray(environment)) { - values.push(...environment); - } - // Tokenize (quote/escape-aware) and only take tokens that ARE an `environment:` - // filter, so dotted keys like `deployment.environment:` and `environment:` - // inside quoted text (e.g. a message value) aren't mistaken for a filter. - const tokens = tokenizeSearchQuery(query); - for (let i = 0; i < tokens.length; i++) { - const match = /^environment:(.*)$/is.exec(tokens[i]); - if (!match) { - continue; - } - let value = match[1]; - // An IN-list (`environment:[a, b]`) can be split across tokens on its - // internal spaces; rejoin following tokens until the list is closed. - while ( - value.startsWith("[") && - !value.includes("]") && - i + 1 < tokens.length - ) { - value += ` ${tokens[++i]}`; - } - const inner = - value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value; - for (const part of inner.split(",")) { - const cleaned = part.trim().replace(/^["']|["']$/g, ""); - if (cleaned) { - values.push(cleaned); - } - } - } - return values; -} - -/** - * Note listing the org's real environments when a search references one that - * doesn't exist, so the caller can retry with a valid name. We don't guess a - * match — the calling agent maps from the list. - */ -export function formatUnknownEnvironmentNote( - unknown: string[], - available: string[], -): string { - const uniqueUnknown = [...new Set(unknown)].map((name) => `\`${name}\``); - const shown = available.slice(0, 50).map((name) => `\`${name}\``); - const more = - available.length > shown.length ? ` (${available.length} total)` : ""; - const label = uniqueUnknown.length === 1 ? "environment" : "environments"; - return `> ⚠️ Requested ${label} not found in this organization: ${uniqueUnknown.join(", ")}. Available environments: ${shown.join(", ")}${more}. Re-run filtering by one of these, or omit the environment to search all.`; -} - -function applyEnvironmentToEventsQuery( - dataset: PublicEventsDataset | "replays", - query: string, - environment?: string | string[] | null, -): string { - if (dataset === "replays") { - return query; - } - return appendSearchFilter(query, formatEnvironmentFilter(environment)); -} - -function tokenizeSearchQuery(query: string): string[] { - const tokens: string[] = []; - let currentToken = ""; - let quote: '"' | "'" | null = null; - let escaped = false; - - for (const char of query) { - if (escaped) { - currentToken += char; - escaped = false; - continue; - } - - if (char === "\\") { - currentToken += char; - escaped = true; - continue; - } - - if (quote) { - currentToken += char; - if (char === quote) { - quote = null; - } - continue; - } - - if (char === '"' || char === "'") { - currentToken += char; - quote = char; - continue; - } - - if (/\s/.test(char)) { - if (currentToken) { - tokens.push(currentToken); - currentToken = ""; - } - continue; - } - - currentToken += char; - } - - if (currentToken) { - tokens.push(currentToken); - } - - return tokens; -} - -function containsSearchToken(query: string, token: string): boolean { - const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`(^|\\s)${escapedToken}(?=\\s|$)`).test(query); -} - -function preservesSearchTokens(originalQuery: string, repairedQuery: string) { - return tokenizeSearchQuery(originalQuery).every((token) => - containsSearchToken(repairedQuery, token), - ); -} - -function choosePreservingRepairedQuery(params: { - originalQuery: string; - repairedQuery?: string | null; - filter?: string; -}): string { - const originalQuery = params.originalQuery.trim(); - const repairedQuery = params.repairedQuery?.trim(); - if (!repairedQuery) { - return appendSearchFilter(originalQuery, params.filter); - } - - if (isSemanticFilterDowngrade(originalQuery, repairedQuery)) { - return appendSearchFilter(originalQuery, params.filter); - } - - if (!originalQuery || preservesSearchTokens(originalQuery, repairedQuery)) { - return appendSearchFilter(repairedQuery, params.filter); - } - - return appendSearchFilter(originalQuery, params.filter); -} - -function buildAgentPrompt(params: { - query?: string; - dataset: PublicEventsDataset | "replays"; - fields?: string[] | null; - sort?: string | null; - statsPeriod?: string; - environment?: string | string[] | null; -}): string { - return [ - "Translate this Sentry event search request.", - "The query may be natural language or already-valid Sentry search syntax.", - "Preserve valid explicit parameters, but correct dataset, query syntax, fields, sort, and time range when they conflict or would fail.", - "If the user query already uses Sentry search syntax, treat its filters as authoritative unless validateSearch proves a field is invalid.", - "Never replace a structured field filter with message/log.body/full-text matching. If no valid attribute exists for an explicit field:value filter, keep the field and let validation fail.", - "For spans, logs, and metrics, use datasetAttributes to discover likely fields with substringMatch, query, and attributeTypes before dropping or renaming explicit fields.", - "A broad datasetAttributes result may be truncated, so absence from that preview does not prove an explicit field is invalid.", - "For non-replay datasets, call validateSearch on the candidate request and fix failures in this same pass before returning.", - "For non-replay datasets, convert environment parameters into query filters. For replays, keep environment in the separate environment parameter.", - "", - `User query: ${params.query || "(empty)"}`, - "Current parameters:", - JSON.stringify( - { - dataset: params.dataset, - fields: params.fields ?? null, - sort: params.sort ?? null, - statsPeriod: params.statsPeriod ?? null, - environment: params.environment ?? null, - }, - null, - 2, - ), - ].join("\n"); -} - export default defineTool({ + ...searchToolBase("search_events"), name: "search_events", - skills: ["inspect", "triage", "seer"], // Available in inspect, triage, and seer skills - requiredScopes: ["event:read"], + includeInSkillDefinitions: false, description: [ - "Search Sentry events and replays. Use for event counts/statistics.", - "", - "`query` is natural language or Sentry search syntax; a configured agent fixes dataset, query, fields, and sort.", + "Deprecated multi-dataset event search, kept for backward compatibility.", "", - "Supports THREE query types:", - "1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'total tokens'", - "2. Individual events with timestamps: 'error logs from last hour'", - "3. TIME SERIES (metric over time): 'errors per hour', 'error trend over time'", - "", - "Datasets:", - "- errors: Exception/crash events with stack traces, usually grouped into issues", - "- logs: Application log entries, including error-severity log messages", - "- spans: Raw trace/span events for performance, AI/LLM calls, requests, and operations", - "- metrics: Metric rows and aggregates: counters, gauges, distributions, values", - "- profiles: Transaction/continuous profile results, profile IDs, profiled transactions", - "- replays: Session replay results: rage clicks, dead clicks, visited pages, replay users", - "If the user says logs, log messages, error logs, or warning logs, choose logs instead of errors.", - "", - "Replay searches return replay lists only; replay count()/avg()/sum() are not supported.", - "", - "NOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).", - "", - "", - "search_events(organizationSlug='my-org', dataset='errors', query='how many errors today')", - "search_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')", - "search_events(organizationSlug='my-org', dataset='errors', query='errors per hour last 24h')", - "search_events(organizationSlug='my-org', dataset='spans', query='span.op:db', sort='-span.duration')", - "search_events(organizationSlug='my-org', dataset='replays', query='count_errors:>0', sort='-count_errors')", - "", - "", - "", - "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", - "- Use fields with aggregate functions like count(), avg(), sum() for statistics", - "- Sort by -count() for most common, -timestamp for newest", - "", + "Use search_errors, search_logs, search_traces, search_metrics, search_profiles, or search_replays for new integrations.", ].join("\n"), - inputSchema: { - organizationSlug: ParamOrganizationSlug, - dataset: z - .enum(SEARCH_EVENTS_DATASETS) - .optional() - .describe( - "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. Always pass it, including for natural language queries. The agent may correct it when configured.", - ), - query: z - .string() - .trim() - .optional() - .describe("Natural language or Sentry event search query syntax."), - fields: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Fields to return for event datasets. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics. Leave null for dataset='replays'.", - ), - sort: z - .string() - .trim() - .nullable() - .optional() - .describe( - "Sort field (prefix with - for descending). If omitted, event datasets default to -timestamp and replays default to -started_at. Use -count() for event aggregations. For dataset='replays', use replay sorts like -started_at or -count_errors.", - ), - projectSlug: ParamProjectSlug.nullable().default(null), - environment: z - .union([ - z.string().trim().min(1), - z.array(z.string().trim().min(1)).min(1), - ]) - // Keep optional (omit when unused). Do not add .nullable(): Zod emits nested - // anyOf for union+null, which some model APIs reject on tool schemas. - .optional() - .describe( - "Optional environment filter for dataset='replays'. Use a string for one environment or an array for multiple. Omit when unused. For other datasets, filter environment in the query string instead.", - ), - period: ParamPeriod.optional(), - regionUrl: ParamRegionUrl.nullable().default(null), - limit: z - .number() - .min(1) - .max(100) - .default(10) - .describe("Maximum number of results to return (1-100)"), - includeExplanation: z - .boolean() - .default(false) - .describe( - "Include explanation of how the query was translated or repaired", - ), - }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: true, - }, - // Log the failing query and how it failed so we can see which real queries - // fail (the failure surfaces as a UserInputError that isn't reported to - // Sentry). Scrubbed here so tokens/emails don't reach any log sink. - onError(error, params, context) { - const query = params.query; - logWarn("search_events query failed", { - loggerScope: ["tools", "search_events"], - extra: { - errorName: error instanceof Error ? error.name : typeof error, - // The message distinguishes causes that share a name (e.g. - // UserInputError: no-output vs validation vs provider outage). - errorMessage: scrubSensitiveText( - error instanceof Error ? error.message : String(error), - ), - organizationSlug: - (typeof params.organizationSlug === "string" - ? params.organizationSlug - : null) ?? context.constraints.organizationSlug, - query: typeof query === "string" ? scrubSensitiveText(query) : null, - }, - }); - }, + inputSchema: buildSearchEventsInputSchema(), async handler(params, context: ServerContext) { - const apiService = apiServiceFromContext(context, { - regionUrl: params.regionUrl ?? undefined, - }); - const organizationSlug = params.organizationSlug; - - setOrganizationContext(organizationSlug); - if (params.projectSlug) setTag("project.slug", params.projectSlug); - - const inputDataset = params.dataset ?? "errors"; - const hasStructuredQuery = looksLikeSentrySearchSyntax(params.query); - const canApplyEnvironmentFilter = - inputDataset !== "replays" && - isTraceItemDataset(inputDataset) && - hasStructuredQuery; - - let projectId: string | undefined; - if (params.projectSlug) { - const project = await apiService.getProject({ - organizationSlug, - projectSlugOrId: params.projectSlug, - }); - projectId = String(project.id); - } - - let dataset: PublicEventsDataset | "replays"; - let sentryQuery: string; - let fields: string[]; - let sortParam: string; - let timeParams: { statsPeriod?: string; start?: string; end?: string }; - let explanation: string | undefined; - let environment: string | string[] | null | undefined = params.environment; - let timeSeries: { yAxis: string; interval: string | null } | null = null; - - const explicitSort = params.sort?.trim() || undefined; - const hasExplicitDataset = params.dataset !== undefined; - const hasExplicitFields = hasFields(params.fields); - const hasExplicitSort = explicitSort !== undefined; - const hasExplicitPeriod = params.period !== undefined; - const hasExplicitTraceItemDataset = - hasExplicitDataset && isTraceItemDataset(inputDataset); - const shouldTrustStructuredTraceSearch = - hasStructuredQuery && hasExplicitTraceItemDataset; - const environmentFilter = formatEnvironmentFilter(params.environment); - const explicitStructuredTraceQuery = shouldTrustStructuredTraceSearch - ? appendSearchFilter(params.query ?? "", environmentFilter) - : (params.query ?? ""); - const canRunWithoutAgent = - shouldTrustStructuredTraceSearch && hasExplicitFields && hasExplicitSort; - - // Fetch the org's real environments once: used to ground the agent prompt - // (below) and to flag any requested environment that doesn't exist. Skipped - // only when nothing references an environment — including a structured query - // that skips the agent but puts `environment:` in the query string. - // Seer only translates into the dataset it is given, so it runs only when - // one is explicit. It only sees the natural language query, so skip it for - // structured queries and explicit fields or sort, which the embedded agent - // preserves. Like the UI, an explicit environment is added to Seer's query - // afterwards. - const seerTranslation = - context.experimentalMode && - params.query && - isSeerSearchDataset(params.dataset) && - !hasStructuredQuery && - !hasExplicitFields && - !hasExplicitSort - ? await translateWithSeer({ - apiService, - organizationSlug, - projectId, - dataset: params.dataset, - query: params.query, - }) - : null; - if (seerTranslation && !projectId) { - projectId = "-1"; - } - - if ( - !hasAgentProvider() && - inputDataset !== "replays" && - params.environment && - !canApplyEnvironmentFilter && - !seerTranslation - ) { - throw new UserInputError( - "The `environment` parameter is only supported for dataset='replays'. For other datasets, include environment filtering in the query string instead.", - ); - } - - const willRunAgent = - hasAgentProvider() && !canRunWithoutAgent && !seerTranslation; - const inputReferencesEnvironment = - params.environment != null || - collectRequestedEnvironments(null, params.query ?? "").length > 0; - const environmentNames = - willRunAgent || inputReferencesEnvironment - ? await fetchEnvironmentNames({ - apiService, - organizationSlug, - projectId, - }) - : []; - const knownEnvironments = new Set( - environmentNames.map((name) => name.toLowerCase()), - ); - - if (seerTranslation) { - dataset = inputDataset; - sentryQuery = seerTranslation.query; - fields = seerTranslation.fields; - sortParam = seerTranslation.sort; - // Seer never sees `period`, so an explicit one wins over its time range. - timeParams = hasExplicitPeriod - ? { statsPeriod: params.period } - : seerTranslation.timeParams; - explanation = seerTranslation.explanation; - timeSeries = seerTranslation.timeSeries; - } else if (willRunAgent) { - const parsed = await withProviderFallback({ - operation: "search_events.rewrite", - fallback: () => ({ - dataset: inputDataset, - query: params.query ?? "", - fields: - inputDataset === "replays" - ? [] - : (params.fields ?? defaultFieldsForDataset(inputDataset)), - sort: explicitSort || defaultSortForDataset(inputDataset), - environment: params.environment ?? null, - timeSeries: null, - timeRange: { statsPeriod: params.period ?? "14d" }, - explanation: "", - }), - run: async () => - ( - await searchEventsAgent({ - query: buildAgentPrompt({ - query: params.query, - dataset: inputDataset, - fields: params.fields, - sort: params.sort, - statsPeriod: params.period, - environment: params.environment, - }), - organizationSlug, - apiService, - projectId, - environmentNames, - }) - ).result, - }); - const shouldTrustExplicitSearchParams = - shouldTrustStructuredTraceSearch || - (hasStructuredQuery && parsed.dataset === inputDataset); - - timeSeries = parsed.timeSeries ?? null; - - // Time series requests use yAxis/interval, so sort is not required. - if ( - !timeSeries && - !parsed.sort?.trim() && - !(shouldTrustExplicitSearchParams && hasExplicitSort) - ) { - 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).`, - ); - } - - dataset = shouldTrustStructuredTraceSearch - ? inputDataset - : parsed.dataset; - sentryQuery = shouldTrustStructuredTraceSearch - ? choosePreservingRepairedQuery({ - originalQuery: params.query ?? "", - repairedQuery: parsed.query, - filter: environmentFilter, - }) - : looksLikeSentrySearchSyntax(params.query) && - isSemanticFilterDowngrade(params.query ?? "", parsed.query || "") - ? (params.query ?? "") - : parsed.query || ""; - sortParam = - shouldTrustExplicitSearchParams && explicitSort - ? explicitSort - : parsed.sort?.trim() || defaultSortForDataset(dataset); - explanation = parsed.explanation; - environment = params.environment ?? parsed.environment; - - timeParams = - shouldTrustExplicitSearchParams && hasExplicitPeriod - ? { statsPeriod: params.period } - : (parseAgentTimeRange(parsed.timeRange) ?? { statsPeriod: "14d" }); - - if (dataset === "replays") { - fields = []; - } else { - fields = resolveEventFields({ - dataset, - explicitFields: params.fields, - agentFields: parsed.fields, - trustExplicitFields: shouldTrustExplicitSearchParams, - }); - } - } else { - dataset = inputDataset; - sentryQuery = shouldTrustStructuredTraceSearch - ? explicitStructuredTraceQuery - : (params.query ?? ""); - sortParam = explicitSort || defaultSortForDataset(dataset); - timeParams = { statsPeriod: params.period ?? "14d" }; - fields = - dataset === "replays" - ? [] - : (params.fields ?? defaultFieldsForDataset(dataset)); - } - - // Flag any requested environment that doesn't exist (checking both the - // separate field and `environment:` tokens in the query) so the caller can - // retry with a valid name instead of silently getting zero results. - const unknownEnvironments = - knownEnvironments.size > 0 - ? collectRequestedEnvironments(environment, sentryQuery).filter( - (name) => !knownEnvironments.has(name.toLowerCase()), - ) - : []; - const environmentNote = - unknownEnvironments.length > 0 - ? formatUnknownEnvironmentNote(unknownEnvironments, environmentNames) - : ""; - // The caller chose the project (or the session is scoped to it), so Seer's - // wider scope is only suggested. Scoped sessions can't change the project. - const suggestedProjectIds = context.constraints.projectSlug - ? [] - : (seerTranslation?.suggestedProjectIds ?? []); - const projectSuggestionNote = - suggestedProjectIds.length > 0 - ? `**Note:** Seer suggested also searching project IDs ${suggestedProjectIds.join(", ")}, for example other services in the same trace. Omit \`projectSlug\` to search all accessible projects.` - : ""; - const leadingNote = [ - seerTranslation?.warning, - environmentNote, - projectSuggestionNote, - ] - .filter(Boolean) - .join("\n\n"); - const withLeadingNote = (text: string): string => - leadingNote ? `${leadingNote}\n\n${text}` : text; - - if (dataset === "replays") { - const replaySort = sortParam || DEFAULT_REPLAY_SORT; - if (!isValidReplaySort(replaySort)) { - throw new UserInputError( - `Invalid replay sort "${replaySort}". Use a supported replay sort like ${DEFAULT_REPLAY_SORT}, -count_errors, -count_rage_clicks, or -duration.`, - ); - } - - const replayTimeParams: { - statsPeriod?: string; - start?: string; - end?: string; - } = { ...timeParams }; - if ( - !replayTimeParams.statsPeriod && - !replayTimeParams.start && - !replayTimeParams.end - ) { - replayTimeParams.statsPeriod = DEFAULT_REPLAY_STATS_PERIOD; - } - - const replays = await apiService.searchReplays({ - organizationSlug, - query: sentryQuery, - limit: params.limit, - projectId, - sort: replaySort, - environment: environment ?? undefined, - ...replayTimeParams, - }); - - const replaySearchUrl = apiService.getReplaysSearchUrl(organizationSlug, { - query: sentryQuery || undefined, - projectSlugOrId: projectId, - environment: environment ?? undefined, - sort: replaySort, - ...replayTimeParams, - }); - - getActiveSpan()?.setAttribute( - "gen_ai.tool.call.result.count", - replays.length, - ); - - const replayOutput = formatReplayResults({ - replays, - inputQuery: params.query || sentryQuery || "recent replays", - includeExplanation: params.includeExplanation, - organizationSlug, - apiService, - searchUrl: replaySearchUrl, - replayQuery: sentryQuery, - sort: replaySort, - environment, - explanation, - timeRange: replayTimeParams, - executedSearch: { - dataset, - query: sentryQuery, - fields: [], - sort: replaySort, - timeRange: replayTimeParams, - }, - experimentalMode: context.experimentalMode ?? false, - availableToolNames: context.availableToolNames, - directToolNames: context.directToolNames, - }); - return withLeadingNote(replayOutput); - } - - if (timeSeries) { - const timeSeriesQuery = applyEnvironmentToEventsQuery( - dataset, - sentryQuery, - environment, - ); - // No validateEventsSearch here: it validates the /events/ (discover) - // request shape — fields + orderby — which is not what a timeseries - // sends (yAxis + interval, no fields/sort). events-stats validates the - // query server-side, so a bad query still surfaces as an API error. - const series = await apiService.getEventsTimeSeries({ - organizationSlug, - query: timeSeriesQuery, - yAxis: timeSeries.yAxis, - interval: timeSeries.interval ?? undefined, - projectId, - dataset, - ...timeParams, - }); - const statsUrl = apiService.getEventsExplorerUrl( - organizationSlug, - timeSeriesQuery, - projectId, - dataset, - [timeSeries.yAxis], - `-${timeSeries.yAxis}`, - [timeSeries.yAxis], - [], - timeParams.statsPeriod, - timeParams.start, - timeParams.end, - ); - return withLeadingNote( - formatTimeSeriesResults({ - series, - yAxis: timeSeries.yAxis, - interval: timeSeries.interval, - inputQuery: params.query || timeSeriesQuery, - includeExplanation: params.includeExplanation, - explanation, - timeRange: timeParams, - url: statsUrl, - }), - ); - } - - // Sentry rejects the request if the sort column isn't in the selected - // fields. The embedded agent's schema enforces this, but the handler can - // recombine the caller's explicit fields with a default or explicit sort - // that the agent never saw — so re-check here. - // - // Skip the augment when the sort is non-aggregate but the existing fields - // are aggregate: adding a non-aggregate column to an aggregate query - // changes the GROUP BY and silently corrupts the result. Better to let - // Sentry's 400 propagate so the caller can fix the request explicitly. - // - // Note: fields and sortParam use the same function syntax sent to the API. - fields = augmentFieldsWithSort(fields, sortParam); - - const requestFields = buildRequestFields(dataset, fields); - - // Final gate only. The agent should already have used validateSearch while - // constructing the request; the handler does not run a second repair agent. - sentryQuery = applyEnvironmentToEventsQuery( - dataset, - sentryQuery, - environment, - ); - const lastValidation = await validateEventsSearch(apiService, { - organizationSlug, - dataset, - fields: requestFields, - query: sentryQuery, - sort: sortParam, - projectId, - environment: environment ?? undefined, - ...timeParams, - }); - recordEventsSearchValidationTelemetry({ - attempt: 0, - validation: lastValidation, - }); - - if (!lastValidation.valid) { - const formatted = formatEventsValidationResults(lastValidation); - throw new UserInputError( - formatted - ? `Search validation failed:\n${formatted}` - : "Search validation failed.", - ); - } - - const finalRequestFields = buildRequestFields(dataset, fields); - sentryQuery = applyEnvironmentToEventsQuery( - dataset, - sentryQuery, - environment, - ); - - const eventsResponse = await apiService.searchEvents({ - organizationSlug, - query: sentryQuery, - fields: finalRequestFields, - limit: params.limit, - projectId, - dataset, - sort: sortParam, - crossEventQueries: seerTranslation?.crossEventQueries, - ...timeParams, - }); - - const aggregateFunctions = fields.filter( - (field) => field.includes("(") && field.includes(")"), - ); - const groupByFields = fields.filter( - (field) => !field.includes("(") && !field.includes(")"), - ); - - 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"); - } - - getActiveSpan()?.setAttribute( - "gen_ai.tool.call.result.count", - eventData.length, - ); - - const conversationId = extractConversationIdFromSearchQuery(sentryQuery); - const explorerUrl = conversationId - ? apiService.getAIConversationUrl(organizationSlug, conversationId) - : apiService.getEventsExplorerUrl( - organizationSlug, - sentryQuery, - projectId, - dataset, - fields, - sortParam, - aggregateFunctions, - groupByFields, - timeParams.statsPeriod, - timeParams.start, - timeParams.end, - eventData, - ); - - const formatParams = { - eventData, - inputQuery: params.query || sentryQuery || `${dataset} events`, - includeExplanation: params.includeExplanation, - apiService, - organizationSlug, - explorerUrl, - sentryQuery, - fields, - explanation, - executedSearch: { - dataset, - query: sentryQuery, - fields, - sort: sortParam, - timeRange: timeParams, - }, - experimentalMode: context.experimentalMode ?? false, - availableToolNames: context.availableToolNames, - directToolNames: context.directToolNames, - }; - - switch (dataset) { - case "errors": - return withLeadingNote(formatErrorResults(formatParams)); - case "logs": - return withLeadingNote(formatLogResults(formatParams)); - case "spans": - return withLeadingNote(formatSpanResults(formatParams)); - case "profiles": - return withLeadingNote(formatProfileResults(formatParams)); - default: - return withLeadingNote(formatTraceMetricsResults(formatParams)); - } + return runSearchEvents(params, context); }, }); diff --git a/packages/mcp-core/src/tools/catalog/search-issues.test.ts b/packages/mcp-core/src/tools/catalog/search-issues.test.ts index dcfbb64ee..70f03852a 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.test.ts @@ -186,7 +186,7 @@ describe("search_issues", () => { - Get more details about a specific issue: Use get_sentry_resource with the issue ID or issue URL - Update issue status: Use the Sentry tool \`update_issue\` to resolve or assign issues - - View event counts: Use search_events for aggregated statistics + - View event counts: Use search_errors for aggregated statistics " `); }); diff --git a/packages/mcp-core/src/tools/catalog/search-issues.ts b/packages/mcp-core/src/tools/catalog/search-issues.ts index d8550ba9a..e158502f2 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.ts @@ -58,8 +58,8 @@ export default defineTool({ "- environment:production", "- userCount:>100", "", - "DO NOT USE FOR COUNTS/AGGREGATIONS → use search_events", - "DO NOT USE FOR individual events with timestamps → use search_events", + "DO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors", + "DO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces", "DO NOT USE FOR details about a specific issue → use get_sentry_resource", "", "", diff --git a/packages/mcp-core/src/tools/catalog/search-logs.test.ts b/packages/mcp-core/src/tools/catalog/search-logs.test.ts new file mode 100644 index 000000000..69145964a --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-logs.test.ts @@ -0,0 +1,131 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { generateText } from "ai"; +import { HttpResponse, http } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import searchLogs from "./search-logs"; + +vi.mock("@ai-sdk/openai", () => { + const mockModel = vi.fn(() => "mocked-model"); + return { + openai: mockModel, + createOpenAI: vi.fn(() => mockModel), + }; +}); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: vi.fn(), + tool: vi.fn(() => ({ execute: vi.fn() })), + Output: { object: vi.fn(() => ({})) }, + }; +}); + +const context = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", +}; + +function agentResponse(output: Record) { + return { + text: JSON.stringify(output), + experimental_output: output, + finishReason: "stop" as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + warnings: [] as const, + } as any; +} + +describe("search_logs", () => { + const mockGenerateText = vi.mocked(generateText); + + beforeEach(() => { + vi.clearAllMocks(); + process.env.OPENAI_API_KEY = "test-key"; + process.env.OPENROUTER_API_KEY = ""; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/environments/", + () => HttpResponse.json([]), + ), + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", + () => + HttpResponse.json({ + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: true, error: null, fields: [] }, + orderby: [], + }), + ), + ); + }); + + it("takes its dataset from the tool instead of a parameter", () => { + expect(Object.keys(searchLogs.inputSchema)).toMatchInlineSnapshot(` + [ + "organizationSlug", + "query", + "fields", + "sort", + "projectSlug", + "period", + "regionUrl", + "limit", + "includeExplanation", + ] + `); + }); + + it("keeps the logs dataset when the agent suggests another", async () => { + mockGenerateText.mockResolvedValue( + agentResponse({ + dataset: "errors", + query: "severity:error", + fields: ["timestamp", "message", "severity"], + sort: "-timestamp", + environment: null, + timeRange: { statsPeriod: "24h" }, + explanation: "Test query translation", + }), + ); + const requestedDatasets: Array = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + requestedDatasets.push( + new URL(request.url).searchParams.get("dataset"), + ); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchLogs.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "error logs from the last hour", + limit: 10, + includeExplanation: false, + }, + context, + ); + + expect(requestedDatasets).toEqual(["logs"]); + expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( + "The dataset is fixed to logs", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/search-logs.ts b/packages/mcp-core/src/tools/catalog/search-logs.ts new file mode 100644 index 000000000..1a60689ed --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-logs.ts @@ -0,0 +1,38 @@ +import { defineTool } from "../../internal/tool-helpers/define"; +import type { ServerContext } from "../../types"; +import { + buildDatasetSearchInputSchema, + runSearchEvents, + searchToolBase, +} from "../support/search-events/search"; + +export default defineTool({ + ...searchToolBase("search_logs"), + name: "search_logs", + description: [ + "Search Sentry logs: application log entries, including error- and warning-severity log messages. Use for log counts, statistics, trends, and individual log lines.", + "", + "`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.", + "", + "Supports aggregations ('warning logs by service'), individual entries ('error logs from the last hour'), and time series ('error logs per hour').", + "", + "NOT for exceptions/crashes (use search_errors). For requests or spans whose trace also has a matching log, use search_traces.", + "", + "", + "search_logs(organizationSlug='my-org', query='error logs from the last hour')", + "search_logs(organizationSlug='my-org', query='logs mentioning payment timeout in production')", + "search_logs(organizationSlug='my-org', query='count warning logs by service over 7 days')", + "", + "", + "", + "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", + "- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.", + "", + ].join("\n"), + inputSchema: buildDatasetSearchInputSchema(), + async handler(params, context: ServerContext) { + return runSearchEvents({ ...params, dataset: "logs" }, context, { + lockDataset: true, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/search-metrics.test.ts b/packages/mcp-core/src/tools/catalog/search-metrics.test.ts new file mode 100644 index 000000000..0a7e9fd4a --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-metrics.test.ts @@ -0,0 +1,131 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { generateText } from "ai"; +import { HttpResponse, http } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import searchMetrics from "./search-metrics"; + +vi.mock("@ai-sdk/openai", () => { + const mockModel = vi.fn(() => "mocked-model"); + return { + openai: mockModel, + createOpenAI: vi.fn(() => mockModel), + }; +}); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: vi.fn(), + tool: vi.fn(() => ({ execute: vi.fn() })), + Output: { object: vi.fn(() => ({})) }, + }; +}); + +const context = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", +}; + +function agentResponse(output: Record) { + return { + text: JSON.stringify(output), + experimental_output: output, + finishReason: "stop" as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + warnings: [] as const, + } as any; +} + +describe("search_metrics", () => { + const mockGenerateText = vi.mocked(generateText); + + beforeEach(() => { + vi.clearAllMocks(); + process.env.OPENAI_API_KEY = "test-key"; + process.env.OPENROUTER_API_KEY = ""; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/environments/", + () => HttpResponse.json([]), + ), + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", + () => + HttpResponse.json({ + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: true, error: null, fields: [] }, + orderby: [], + }), + ), + ); + }); + + it("takes its dataset from the tool instead of a parameter", () => { + expect(Object.keys(searchMetrics.inputSchema)).toMatchInlineSnapshot(` + [ + "organizationSlug", + "query", + "fields", + "sort", + "projectSlug", + "period", + "regionUrl", + "limit", + "includeExplanation", + ] + `); + }); + + it("keeps the metrics dataset when the agent suggests another", async () => { + mockGenerateText.mockResolvedValue( + agentResponse({ + dataset: "spans", + query: "metric.name:http.request.duration", + fields: ["timestamp", "metric.name", "value"], + sort: "-timestamp", + environment: null, + timeRange: { statsPeriod: "24h" }, + explanation: "Test query translation", + }), + ); + const requestedDatasets: Array = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + requestedDatasets.push( + new URL(request.url).searchParams.get("dataset"), + ); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchMetrics.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "recent request duration metrics", + limit: 10, + includeExplanation: false, + }, + context, + ); + + expect(requestedDatasets).toEqual(["tracemetrics"]); + expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( + "The dataset is fixed to metrics", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/search-metrics.ts b/packages/mcp-core/src/tools/catalog/search-metrics.ts new file mode 100644 index 000000000..831bead81 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-metrics.ts @@ -0,0 +1,37 @@ +import { defineTool } from "../../internal/tool-helpers/define"; +import type { ServerContext } from "../../types"; +import { + buildDatasetSearchInputSchema, + runSearchEvents, + searchToolBase, +} from "../support/search-events/search"; + +export default defineTool({ + ...searchToolBase("search_metrics"), + name: "search_metrics", + description: [ + "Search Sentry metrics: counters, gauges, and distributions, as rows or aggregates. Use for metric values, percentiles, totals, and trends.", + "", + "`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.", + "", + "Supports aggregations ('p95 http.request.duration by environment'), individual metric rows, and time series ('total tokens per day this week').", + "", + "NOT for span or request latency recorded on traces (use search_traces).", + "", + "", + "search_metrics(organizationSlug='my-org', query='p95 http.request.duration grouped by environment over the last 24 hours')", + "search_metrics(organizationSlug='my-org', query='total tokens used per day this week')", + "", + "", + "", + "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", + "- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.", + "", + ].join("\n"), + inputSchema: buildDatasetSearchInputSchema(), + async handler(params, context: ServerContext) { + return runSearchEvents({ ...params, dataset: "metrics" }, context, { + lockDataset: true, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/search-profiles.test.ts b/packages/mcp-core/src/tools/catalog/search-profiles.test.ts new file mode 100644 index 000000000..f3219e916 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-profiles.test.ts @@ -0,0 +1,131 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { generateText } from "ai"; +import { HttpResponse, http } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import searchProfiles from "./search-profiles"; + +vi.mock("@ai-sdk/openai", () => { + const mockModel = vi.fn(() => "mocked-model"); + return { + openai: mockModel, + createOpenAI: vi.fn(() => mockModel), + }; +}); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: vi.fn(), + tool: vi.fn(() => ({ execute: vi.fn() })), + Output: { object: vi.fn(() => ({})) }, + }; +}); + +const context = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", +}; + +function agentResponse(output: Record) { + return { + text: JSON.stringify(output), + experimental_output: output, + finishReason: "stop" as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + warnings: [] as const, + } as any; +} + +describe("search_profiles", () => { + const mockGenerateText = vi.mocked(generateText); + + beforeEach(() => { + vi.clearAllMocks(); + process.env.OPENAI_API_KEY = "test-key"; + process.env.OPENROUTER_API_KEY = ""; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/environments/", + () => HttpResponse.json([]), + ), + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", + () => + HttpResponse.json({ + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: true, error: null, fields: [] }, + orderby: [], + }), + ), + ); + }); + + it("takes its dataset from the tool instead of a parameter", () => { + expect(Object.keys(searchProfiles.inputSchema)).toMatchInlineSnapshot(` + [ + "organizationSlug", + "query", + "fields", + "sort", + "projectSlug", + "period", + "regionUrl", + "limit", + "includeExplanation", + ] + `); + }); + + it("keeps the profiles dataset when the agent suggests another", async () => { + mockGenerateText.mockResolvedValue( + agentResponse({ + dataset: "spans", + query: "transaction:/checkout", + fields: ["profile.id", "transaction", "timestamp"], + sort: "-timestamp", + environment: null, + timeRange: { statsPeriod: "24h" }, + explanation: "Test query translation", + }), + ); + const requestedDatasets: Array = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + requestedDatasets.push( + new URL(request.url).searchParams.get("dataset"), + ); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchProfiles.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "recent checkout profiles", + limit: 10, + includeExplanation: false, + }, + context, + ); + + expect(requestedDatasets).toEqual(["profiles"]); + expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( + "The dataset is fixed to profiles", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/search-profiles.ts b/packages/mcp-core/src/tools/catalog/search-profiles.ts new file mode 100644 index 000000000..15c350f8f --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-profiles.ts @@ -0,0 +1,34 @@ +import { defineTool } from "../../internal/tool-helpers/define"; +import type { ServerContext } from "../../types"; +import { + buildDatasetSearchInputSchema, + runSearchEvents, + searchToolBase, +} from "../support/search-events/search"; + +export default defineTool({ + ...searchToolBase("search_profiles"), + name: "search_profiles", + description: [ + "Search Sentry profiles: transaction and continuous profile results, profile IDs, and profiled transactions. Use to find profiles to inspect.", + "", + "`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.", + "", + "Use get_profile or get_profile_details on a result for flamegraph and hotspot analysis.", + "", + "", + "search_profiles(organizationSlug='my-org', query='recent profiles for the /checkout transaction')", + "", + "", + "", + "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", + "- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.", + "", + ].join("\n"), + inputSchema: buildDatasetSearchInputSchema(), + async handler(params, context: ServerContext) { + return runSearchEvents({ ...params, dataset: "profiles" }, context, { + lockDataset: true, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/search-replays.test.ts b/packages/mcp-core/src/tools/catalog/search-replays.test.ts new file mode 100644 index 000000000..3f3b998b3 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-replays.test.ts @@ -0,0 +1,136 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { generateText } from "ai"; +import { HttpResponse, http } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import searchReplays from "./search-replays"; + +vi.mock("@ai-sdk/openai", () => { + const mockModel = vi.fn(() => "mocked-model"); + return { + openai: mockModel, + createOpenAI: vi.fn(() => mockModel), + }; +}); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: vi.fn(), + tool: vi.fn(() => ({ execute: vi.fn() })), + Output: { object: vi.fn(() => ({})) }, + }; +}); + +const context = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", +}; + +function agentResponse(output: Record) { + return { + text: JSON.stringify(output), + experimental_output: output, + finishReason: "stop" as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + warnings: [] as const, + } as any; +} + +describe("search_replays", () => { + const mockGenerateText = vi.mocked(generateText); + + beforeEach(() => { + vi.clearAllMocks(); + process.env.OPENAI_API_KEY = "test-key"; + process.env.OPENROUTER_API_KEY = ""; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/environments/", + () => HttpResponse.json([]), + ), + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", + () => + HttpResponse.json({ + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: true, error: null, fields: [] }, + orderby: [], + }), + ), + ); + }); + + it("takes its dataset from the tool instead of a parameter", () => { + expect(Object.keys(searchReplays.inputSchema)).toMatchInlineSnapshot(` + [ + "organizationSlug", + "query", + "sort", + "projectSlug", + "environment", + "period", + "regionUrl", + "limit", + "includeExplanation", + ] + `); + }); + + it("keeps the replays dataset when the agent suggests another", async () => { + mockGenerateText.mockResolvedValue( + agentResponse({ + dataset: "errors", + query: "count_errors:>0", + fields: [], + sort: "-count_errors", + environment: null, + timeRange: { statsPeriod: "24h" }, + explanation: "Test query translation", + }), + ); + const requestedPaths: string[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/replays/", + ({ request }) => { + requestedPaths.push(new URL(request.url).pathname); + return HttpResponse.json({ data: [] }); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + requestedPaths.push(new URL(request.url).pathname); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchReplays.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "replays with errors in the last day", + limit: 10, + includeExplanation: false, + }, + context, + ); + + expect(requestedPaths).toEqual(["/api/0/organizations/test-org/replays/"]); + expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( + "The dataset is fixed to replays", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/search-replays.ts b/packages/mcp-core/src/tools/catalog/search-replays.ts new file mode 100644 index 000000000..8d14291c9 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-replays.ts @@ -0,0 +1,34 @@ +import { defineTool } from "../../internal/tool-helpers/define"; +import type { ServerContext } from "../../types"; +import { + buildReplaySearchInputSchema, + runSearchEvents, + searchToolBase, +} from "../support/search-events/search"; + +export default defineTool({ + ...searchToolBase("search_replays"), + name: "search_replays", + description: [ + "Search Sentry session replays: rage clicks, dead clicks, visited pages, errors seen, and replay users.", + "", + "`query` is natural language (preferred) or replay search syntax; a configured agent translates it into a replay search.", + "", + "Returns replay lists only; count()/avg()/sum() are not supported. Use get_replay_details on a result for one replay.", + "", + "", + "search_replays(organizationSlug='my-org', query='replays with rage clicks on checkout in the last day')", + "search_replays(organizationSlug='my-org', query='count_errors:>0', sort='-count_errors')", + "", + "", + "", + "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", + "", + ].join("\n"), + inputSchema: buildReplaySearchInputSchema(), + async handler(params, context: ServerContext) { + return runSearchEvents({ ...params, dataset: "replays" }, context, { + lockDataset: true, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/search-traces.test.ts b/packages/mcp-core/src/tools/catalog/search-traces.test.ts new file mode 100644 index 000000000..9da35968e --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-traces.test.ts @@ -0,0 +1,209 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { generateText } from "ai"; +import { HttpResponse, http } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import searchTraces from "./search-traces"; + +vi.mock("@ai-sdk/openai", () => { + const mockModel = vi.fn(() => "mocked-model"); + return { + openai: mockModel, + createOpenAI: vi.fn(() => mockModel), + }; +}); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: vi.fn(), + tool: vi.fn(() => ({ execute: vi.fn() })), + Output: { object: vi.fn(() => ({})) }, + }; +}); + +const context = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", +}; + +function agentResponse(output: Record) { + return { + text: JSON.stringify(output), + experimental_output: output, + finishReason: "stop" as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + warnings: [] as const, + } as any; +} + +describe("search_traces", () => { + const mockGenerateText = vi.mocked(generateText); + + beforeEach(() => { + vi.clearAllMocks(); + process.env.OPENAI_API_KEY = "test-key"; + process.env.OPENROUTER_API_KEY = ""; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/environments/", + () => HttpResponse.json([]), + ), + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", + () => + HttpResponse.json({ + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: true, error: null, fields: [] }, + orderby: [], + }), + ), + ); + }); + + it("takes its dataset from the tool instead of a parameter", () => { + expect(Object.keys(searchTraces.inputSchema)).toMatchInlineSnapshot(` + [ + "organizationSlug", + "query", + "fields", + "sort", + "projectSlug", + "period", + "regionUrl", + "limit", + "includeExplanation", + ] + `); + }); + + it("keeps the spans dataset when the agent suggests another", async () => { + mockGenerateText.mockResolvedValue( + agentResponse({ + dataset: "logs", + query: "span.op:db", + fields: ["span.op", "span.duration", "timestamp"], + sort: "-timestamp", + environment: null, + timeRange: { statsPeriod: "24h" }, + explanation: "Test query translation", + }), + ); + const requestedDatasets: Array = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + requestedDatasets.push( + new URL(request.url).searchParams.get("dataset"), + ); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchTraces.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "slow db queries", + limit: 10, + includeExplanation: false, + }, + context, + ); + + expect(requestedDatasets).toEqual(["spans"]); + expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( + "The dataset is fixed to spans", + ); + }); + + it("sends natural language queries to Seer's Traces strategy", async () => { + const seerStartBodies: unknown[] = []; + mswServer.use( + http.get("https://sentry.io/api/0/organizations/test-org/", () => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + features: ["gen-ai-search-agent-translate"], + hideAiFeatures: false, + }), + ), + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + async ({ request }) => { + seerStartBodies.push(await request.json()); + return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/search-agent/state/run-uuid/", + () => + HttpResponse.json({ + sentry_run_id: "run-uuid", + session: { + status: "completed", + final_response: { + responses: [ + { + query: "span.op:http.client", + group_by: [], + visualization: [], + sort: "-span.duration", + stats_period: "24h", + start: null, + end: null, + mode: "samples", + }, + ], + unsupported_reason: null, + }, + }, + }), + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("dataset")).toBe("spans"); + expect(url.searchParams.get("query")).toBe("span.op:http.client"); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const result = await searchTraces.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "slowest api calls in the last 24 hours", + limit: 10, + includeExplanation: true, + }, + { ...context, experimentalMode: true }, + ); + + expect(seerStartBodies).toEqual([ + { + project_ids: [-1], + natural_language_query: "slowest api calls in the last 24 hours", + strategy: "Traces", + }, + ]); + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("Translated by Seer's search agent."); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/search-traces.ts b/packages/mcp-core/src/tools/catalog/search-traces.ts new file mode 100644 index 000000000..c15efeb22 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-traces.ts @@ -0,0 +1,40 @@ +import { defineTool } from "../../internal/tool-helpers/define"; +import type { ServerContext } from "../../types"; +import { + buildDatasetSearchInputSchema, + runSearchEvents, + searchToolBase, +} from "../support/search-events/search"; + +export default defineTool({ + ...searchToolBase("search_traces"), + name: "search_traces", + description: [ + "Search Sentry spans and traces: requests, API/HTTP calls, endpoints, DB queries, AI/LLM calls, and other operations. Use for latency, throughput, slowness, and performance questions.", + "", + "`query` is natural language (preferred) or Sentry search syntax; a configured agent translates it into query, fields, and sort.", + "", + "Also use for spans whose trace contains a matching log, metric, or other span, e.g. 'slow checkout requests that also logged an error'.", + "", + "Supports aggregations ('p95 duration by span.op'), individual spans ('slowest API calls today'), and time series ('requests per minute last hour').", + "", + "NOT for exceptions/crashes (use search_errors), standalone log lines (use search_logs), or a single trace's span tree (use get_sentry_resource with the trace).", + "", + "", + "search_traces(organizationSlug='my-org', query='slowest API calls in the last 24 hours')", + "search_traces(organizationSlug='my-org', query='p95 duration of db spans grouped by span.op over 7 days')", + "search_traces(organizationSlug='my-org', query='checkout requests that also have an error log')", + "", + "", + "", + "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", + "- Natural language is usually enough. Only pass fields/sort when you need exact columns or ordering.", + "", + ].join("\n"), + inputSchema: buildDatasetSearchInputSchema(), + async handler(params, context: ServerContext) { + return runSearchEvents({ ...params, dataset: "spans" }, context, { + lockDataset: true, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/support/profile/formatter.ts b/packages/mcp-core/src/tools/support/profile/formatter.ts index 5906b844d..324b7ba45 100644 --- a/packages/mcp-core/src/tools/support/profile/formatter.ts +++ b/packages/mcp-core/src/tools/support/profile/formatter.ts @@ -567,7 +567,7 @@ export function formatTransactionProfileAnalysis( ); } sections.push( - "- Use `search_events` with the profiles dataset to find similar profiles", + "- Use `search_profiles` to find similar profiles", ); return sections.join("\n"); diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts new file mode 100644 index 000000000..a2147be85 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -0,0 +1,1124 @@ +import { getActiveSpan, setTag } from "@sentry/core"; +import { z } from "zod"; +import { setOrganizationContext } from "../../../telem/organization"; +import { UserInputError } from "../../../errors"; +import { hasAgentProvider } from "../../../internal/agents/provider-factory"; +import { withProviderFallback } from "../../../internal/agents/provider-fallback"; +import { apiServiceFromContext } from "../../../internal/tool-helpers/api"; +import { + ParamOrganizationSlug, + ParamPeriod, + ParamProjectSlug, + ParamRegionUrl, +} from "../../../schema"; +import { logWarn } from "../../../telem/logging"; +import { scrubSensitiveText } from "../../../telem/sentry"; +import type { Scope } from "../../../permissions"; +import type { Skill } from "../../../skills"; +import type { ServerContext } from "../../../types"; +import { + isMetricsDataset, + normalizeEventsDataset, + PUBLIC_EVENTS_DATASETS, + type PublicEventsDataset, +} from "../../../utils/events-datasets"; +import { extractConversationIdFromSearchQuery } from "../../../utils/url-utils"; +import { + fetchEnvironmentNames, + searchEventsAgent, + type searchEventsAgentOutputSchema, +} from "./agent"; +import { + RECOMMENDED_FIELDS, + TRACE_METRICS_SAMPLE_IDENTITY_FIELDS, +} from "./config"; +import { + formatErrorResults, + formatLogResults, + formatProfileResults, + formatSpanResults, + formatTimeSeriesResults, + formatTraceMetricsResults, +} from "./formatters"; +import { + DEFAULT_REPLAY_SORT, + DEFAULT_REPLAY_STATS_PERIOD, + formatReplayResults, + isValidReplaySort, +} from "./replays"; +import { isSeerSearchDataset, translateWithSeer } from "./seer"; +import { + formatEventsValidationResults, + isAggregateQuery, + isSemanticFilterDowngrade, + looksLikeSentrySearchSyntax, + recordEventsSearchValidationTelemetry, + validateEventsSearch, +} from "./utils"; + +export const SEARCH_EVENTS_DATASETS = [ + ...PUBLIC_EVENTS_DATASETS, + "replays", +] as const; +const DEFAULT_EVENTS_SORT = "-timestamp"; + +type SearchEventsAgentResult = z.output; + +function defaultSortForDataset(dataset: PublicEventsDataset | "replays") { + return dataset === "replays" ? DEFAULT_REPLAY_SORT : DEFAULT_EVENTS_SORT; +} + +function defaultFieldsForDataset(dataset: PublicEventsDataset): string[] { + return RECOMMENDED_FIELDS[normalizeEventsDataset(dataset)].basic; +} + +function resolveEventFields({ + dataset, + explicitFields, + agentFields, + trustExplicitFields, +}: { + dataset: PublicEventsDataset; + explicitFields?: string[] | null; + agentFields?: string[]; + trustExplicitFields: boolean; +}): string[] { + if (trustExplicitFields && explicitFields && explicitFields.length > 0) { + return explicitFields; + } + if (agentFields && agentFields.length > 0) { + return agentFields; + } + return defaultFieldsForDataset(dataset); +} + +function parseAgentTimeRange( + timeRange: unknown, +): { statsPeriod?: string; start?: string; end?: string } | undefined { + if (typeof timeRange !== "object" || timeRange === null) { + return undefined; + } + + if ("statsPeriod" in timeRange && typeof timeRange.statsPeriod === "string") { + return { statsPeriod: timeRange.statsPeriod }; + } + if ( + "start" in timeRange && + "end" in timeRange && + typeof timeRange.start === "string" && + typeof timeRange.end === "string" + ) { + return { start: timeRange.start, end: timeRange.end }; + } + + return undefined; +} + +function augmentFieldsWithSort(fields: string[], sort: string): string[] { + const sortField = sort.startsWith("-") ? sort.slice(1) : sort; + const sortIsAggregate = sortField.includes("(") && sortField.includes(")"); + if ( + sortField && + !fields.includes(sortField) && + (sortIsAggregate || !isAggregateQuery(fields)) + ) { + return [...fields, sortField]; + } + return fields; +} + +function buildRequestFields( + dataset: PublicEventsDataset | "replays", + fields: string[], +): string[] { + return dataset !== "replays" && + isMetricsDataset(dataset) && + !isAggregateQuery(fields) + ? Array.from(new Set([...fields, ...TRACE_METRICS_SAMPLE_IDENTITY_FIELDS])) + : fields; +} + +function isTraceItemDataset(dataset: PublicEventsDataset | "replays"): boolean { + return dataset === "spans" || dataset === "logs" || dataset === "metrics"; +} + +function hasFields(fields?: string[] | null): fields is string[] { + return Array.isArray(fields) && fields.length > 0; +} + +function formatSearchValue(value: string): string { + return /^[^\s"',[\]]+$/.test(value) ? value : JSON.stringify(value); +} + +function formatEnvironmentFilter( + environment?: string | string[] | null, +): string | undefined { + if (!environment) { + return undefined; + } + + const environments = Array.isArray(environment) ? environment : [environment]; + if (environments.length === 0) { + return undefined; + } + if (environments.length === 1) { + const environmentValue = environments[0]; + return environmentValue === undefined + ? undefined + : `environment:${formatSearchValue(environmentValue)}`; + } + return `environment:[${environments.map(formatSearchValue).join(",")}]`; +} + +function appendSearchFilter(query: string, filter?: string): string { + const trimmedQuery = query.trim(); + if (!filter) { + return trimmedQuery; + } + if (tokenizeSearchQuery(trimmedQuery).includes(filter)) { + return trimmedQuery; + } + return [trimmedQuery, filter].filter(Boolean).join(" "); +} + +/** + * Collect the environment names a search actually filters on — from both the + * separate `environment` field and any `environment:` token in the query string. + * Sentry only validates the former against real environments, so a bad value in + * the query (e.g. a typo) otherwise slips through and silently returns nothing. + */ +export function collectRequestedEnvironments( + environment: string | string[] | null | undefined, + query: string, +): string[] { + const values: string[] = []; + if (typeof environment === "string") { + values.push(environment); + } else if (Array.isArray(environment)) { + values.push(...environment); + } + // Tokenize (quote/escape-aware) and only take tokens that ARE an `environment:` + // filter, so dotted keys like `deployment.environment:` and `environment:` + // inside quoted text (e.g. a message value) aren't mistaken for a filter. + const tokens = tokenizeSearchQuery(query); + for (let i = 0; i < tokens.length; i++) { + const match = /^environment:(.*)$/is.exec(tokens[i]); + if (!match) { + continue; + } + let value = match[1]; + // An IN-list (`environment:[a, b]`) can be split across tokens on its + // internal spaces; rejoin following tokens until the list is closed. + while ( + value.startsWith("[") && + !value.includes("]") && + i + 1 < tokens.length + ) { + value += ` ${tokens[++i]}`; + } + const inner = + value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value; + for (const part of inner.split(",")) { + const cleaned = part.trim().replace(/^["']|["']$/g, ""); + if (cleaned) { + values.push(cleaned); + } + } + } + return values; +} + +/** + * Note listing the org's real environments when a search references one that + * doesn't exist, so the caller can retry with a valid name. We don't guess a + * match — the calling agent maps from the list. + */ +export function formatUnknownEnvironmentNote( + unknown: string[], + available: string[], +): string { + const uniqueUnknown = [...new Set(unknown)].map((name) => `\`${name}\``); + const shown = available.slice(0, 50).map((name) => `\`${name}\``); + const more = + available.length > shown.length ? ` (${available.length} total)` : ""; + const label = uniqueUnknown.length === 1 ? "environment" : "environments"; + return `> ⚠️ Requested ${label} not found in this organization: ${uniqueUnknown.join(", ")}. Available environments: ${shown.join(", ")}${more}. Re-run filtering by one of these, or omit the environment to search all.`; +} + +function applyEnvironmentToEventsQuery( + dataset: PublicEventsDataset | "replays", + query: string, + environment?: string | string[] | null, +): string { + if (dataset === "replays") { + return query; + } + return appendSearchFilter(query, formatEnvironmentFilter(environment)); +} + +function tokenizeSearchQuery(query: string): string[] { + const tokens: string[] = []; + let currentToken = ""; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (const char of query) { + if (escaped) { + currentToken += char; + escaped = false; + continue; + } + + if (char === "\\") { + currentToken += char; + escaped = true; + continue; + } + + if (quote) { + currentToken += char; + if (char === quote) { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + currentToken += char; + quote = char; + continue; + } + + if (/\s/.test(char)) { + if (currentToken) { + tokens.push(currentToken); + currentToken = ""; + } + continue; + } + + currentToken += char; + } + + if (currentToken) { + tokens.push(currentToken); + } + + return tokens; +} + +function containsSearchToken(query: string, token: string): boolean { + const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|\\s)${escapedToken}(?=\\s|$)`).test(query); +} + +function preservesSearchTokens(originalQuery: string, repairedQuery: string) { + return tokenizeSearchQuery(originalQuery).every((token) => + containsSearchToken(repairedQuery, token), + ); +} + +function choosePreservingRepairedQuery(params: { + originalQuery: string; + repairedQuery?: string | null; + filter?: string; +}): string { + const originalQuery = params.originalQuery.trim(); + const repairedQuery = params.repairedQuery?.trim(); + if (!repairedQuery) { + return appendSearchFilter(originalQuery, params.filter); + } + + if (isSemanticFilterDowngrade(originalQuery, repairedQuery)) { + return appendSearchFilter(originalQuery, params.filter); + } + + if (!originalQuery || preservesSearchTokens(originalQuery, repairedQuery)) { + return appendSearchFilter(repairedQuery, params.filter); + } + + return appendSearchFilter(originalQuery, params.filter); +} + +function buildAgentPrompt(params: { + query?: string; + dataset: PublicEventsDataset | "replays"; + lockDataset?: boolean; + fields?: string[] | null; + sort?: string | null; + statsPeriod?: string; + environment?: string | string[] | null; +}): string { + return [ + "Translate this Sentry event search request.", + "The query may be natural language or already-valid Sentry search syntax.", + params.lockDataset + ? `The dataset is fixed to ${params.dataset} by the calling tool. Keep dataset=${params.dataset} and build the query, fields, and sort for it. Preserve valid explicit parameters, but correct query syntax, fields, sort, and time range when they conflict or would fail.` + : "Preserve valid explicit parameters, but correct dataset, query syntax, fields, sort, and time range when they conflict or would fail.", + "If the user query already uses Sentry search syntax, treat its filters as authoritative unless validateSearch proves a field is invalid.", + "Never replace a structured field filter with message/log.body/full-text matching. If no valid attribute exists for an explicit field:value filter, keep the field and let validation fail.", + "For spans, logs, and metrics, use datasetAttributes to discover likely fields with substringMatch, query, and attributeTypes before dropping or renaming explicit fields.", + "A broad datasetAttributes result may be truncated, so absence from that preview does not prove an explicit field is invalid.", + "For non-replay datasets, call validateSearch on the candidate request and fix failures in this same pass before returning.", + "For non-replay datasets, convert environment parameters into query filters. For replays, keep environment in the separate environment parameter.", + "", + `User query: ${params.query || "(empty)"}`, + "Current parameters:", + JSON.stringify( + { + dataset: params.dataset, + fields: params.fields ?? null, + sort: params.sort ?? null, + statsPeriod: params.statsPeriod ?? null, + environment: params.environment ?? null, + }, + null, + 2, + ), + ].join("\n"); +} + +export type SearchEventsDataset = (typeof SEARCH_EVENTS_DATASETS)[number]; + +export interface SearchEventsParams { + organizationSlug: string; + dataset?: SearchEventsDataset; + query?: string; + fields?: string[] | null; + sort?: string | null; + projectSlug: string | null; + environment?: string | string[]; + period?: string; + regionUrl: string | null; + limit: number; + includeExplanation: boolean; +} + +export interface RunSearchEventsOptions { + /** + * Keep the caller's dataset even when the embedded agent suggests another. + * Dataset-specific tools (search_logs, search_traces, ...) set this so a + * search never returns rows from a different dataset than the tool name. + */ + lockDataset?: boolean; +} + +const searchLimitParam = z + .number() + .min(1) + .max(100) + .default(10) + .describe("Maximum number of results to return (1-100)"); + +const includeExplanationParam = z + .boolean() + .default(false) + .describe("Include explanation of how the query was translated or repaired"); + +const environmentParam = (description: string) => + z + .union([z.string().trim().min(1), z.array(z.string().trim().min(1)).min(1)]) + // Keep optional (omit when unused). Do not add .nullable(): Zod emits nested + // anyOf for union+null, which some model APIs reject on tool schemas. + .optional() + .describe(description); + +/** + * Input schema for the legacy multi-dataset search_events tool. + */ +export function buildSearchEventsInputSchema() { + return { + organizationSlug: ParamOrganizationSlug, + dataset: z + .enum(SEARCH_EVENTS_DATASETS) + .optional() + .describe( + "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. Always pass it, including for natural language queries. The agent may correct it when configured.", + ), + query: z + .string() + .trim() + .optional() + .describe("Natural language or Sentry event search query syntax."), + fields: z + .array(z.string()) + .nullable() + .optional() + .describe( + "Fields to return for event datasets. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics. Leave null for dataset='replays'.", + ), + sort: z + .string() + .trim() + .nullable() + .optional() + .describe( + "Sort field (prefix with - for descending). If omitted, event datasets default to -timestamp and replays default to -started_at. Use -count() for event aggregations. For dataset='replays', use replay sorts like -started_at or -count_errors.", + ), + projectSlug: ParamProjectSlug.nullable().default(null), + environment: environmentParam( + "Optional environment filter for dataset='replays'. Use a string for one environment or an array for multiple. Omit when unused. For other datasets, filter environment in the query string instead.", + ), + period: ParamPeriod.optional(), + regionUrl: ParamRegionUrl.nullable().default(null), + limit: searchLimitParam, + includeExplanation: includeExplanationParam, + }; +} + +/** + * Input schema for dataset-specific event tools (search_errors, search_logs, + * search_traces, search_metrics, search_profiles). The dataset comes from the + * tool, so there is no `dataset` parameter. Environment goes in the query. + */ +export function buildDatasetSearchInputSchema() { + return { + organizationSlug: ParamOrganizationSlug, + query: z + .string() + .trim() + .optional() + .describe( + "What to find, in natural language (preferred) or Sentry search syntax. Include environment, release, or other filters here.", + ), + fields: z + .array(z.string()) + .nullable() + .optional() + .describe( + "Fields to return. If not specified, uses sensible defaults. Include aggregate functions like count(), avg() for statistics.", + ), + sort: z + .string() + .trim() + .nullable() + .optional() + .describe( + "Sort field (prefix with - for descending). Defaults to -timestamp. Use -count() for aggregations.", + ), + projectSlug: ParamProjectSlug.nullable().default(null), + period: ParamPeriod.optional(), + regionUrl: ParamRegionUrl.nullable().default(null), + limit: searchLimitParam, + includeExplanation: includeExplanationParam, + }; +} + +/** + * Input schema for search_replays. Replays return lists only, so there are no + * fields, and environment is a separate parameter. + */ +export function buildReplaySearchInputSchema() { + return { + organizationSlug: ParamOrganizationSlug, + query: z + .string() + .trim() + .optional() + .describe( + "What to find, in natural language (preferred) or replay search syntax.", + ), + sort: z + .string() + .trim() + .nullable() + .optional() + .describe( + "Replay sort (prefix with - for descending): -started_at (default), -count_errors, -count_rage_clicks, or -duration.", + ), + projectSlug: ParamProjectSlug.nullable().default(null), + environment: environmentParam( + "Optional environment filter. Use a string for one environment or an array for multiple. Omit when unused.", + ), + period: ParamPeriod.optional(), + regionUrl: ParamRegionUrl.nullable().default(null), + limit: searchLimitParam, + includeExplanation: includeExplanationParam, + }; +} + +export const SEARCH_EVENTS_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, +}; + +/** + * Shared config for search_events and the dataset-specific search tools. + */ +export function searchToolBase(toolName: string): { + skills: Skill[]; + requiredScopes: Scope[]; + annotations: typeof SEARCH_EVENTS_ANNOTATIONS; + onError( + error: unknown, + params: Record, + context: ServerContext, + ): void; +} { + return { + skills: ["inspect", "triage", "seer"], + requiredScopes: ["event:read"], + annotations: SEARCH_EVENTS_ANNOTATIONS, + onError(error, params, context) { + logSearchEventsError(toolName, error, params, context); + }, + }; +} + +// Log the failing query and how it failed so we can see which real queries +// fail (the failure surfaces as a UserInputError that isn't reported to +// Sentry). Scrubbed here so tokens/emails don't reach any log sink. +export function logSearchEventsError( + toolName: string, + error: unknown, + params: Record, + context: ServerContext, +): void { + const query = params.query; + logWarn(`${toolName} query failed`, { + loggerScope: ["tools", toolName], + extra: { + errorName: error instanceof Error ? error.name : typeof error, + // The message distinguishes causes that share a name (e.g. + // UserInputError: no-output vs validation vs provider outage). + errorMessage: scrubSensitiveText( + error instanceof Error ? error.message : String(error), + ), + organizationSlug: + (typeof params.organizationSlug === "string" + ? params.organizationSlug + : null) ?? context.constraints.organizationSlug, + query: typeof query === "string" ? scrubSensitiveText(query) : null, + }, + }); +} + +/** + * Shared handler for search_events and the dataset-specific search tools. + */ +export async function runSearchEvents( + params: SearchEventsParams, + context: ServerContext, + options: RunSearchEventsOptions = {}, +): Promise { + const lockDataset = options.lockDataset === true; + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + setOrganizationContext(organizationSlug); + if (params.projectSlug) setTag("project.slug", params.projectSlug); + + const inputDataset = params.dataset ?? "errors"; + const hasStructuredQuery = looksLikeSentrySearchSyntax(params.query); + const canApplyEnvironmentFilter = + inputDataset !== "replays" && + isTraceItemDataset(inputDataset) && + hasStructuredQuery; + + let projectId: string | undefined; + if (params.projectSlug) { + const project = await apiService.getProject({ + organizationSlug, + projectSlugOrId: params.projectSlug, + }); + projectId = String(project.id); + } + + let dataset: PublicEventsDataset | "replays"; + let sentryQuery: string; + let fields: string[]; + let sortParam: string; + let timeParams: { statsPeriod?: string; start?: string; end?: string }; + let explanation: string | undefined; + let environment: string | string[] | null | undefined = params.environment; + let timeSeries: { yAxis: string; interval: string | null } | null = null; + + const explicitSort = params.sort?.trim() || undefined; + const hasExplicitDataset = params.dataset !== undefined; + const hasExplicitFields = hasFields(params.fields); + const hasExplicitSort = explicitSort !== undefined; + const hasExplicitPeriod = params.period !== undefined; + const hasExplicitTraceItemDataset = + hasExplicitDataset && isTraceItemDataset(inputDataset); + const shouldTrustStructuredTraceSearch = + hasStructuredQuery && hasExplicitTraceItemDataset; + const environmentFilter = formatEnvironmentFilter(params.environment); + const explicitStructuredTraceQuery = shouldTrustStructuredTraceSearch + ? appendSearchFilter(params.query ?? "", environmentFilter) + : (params.query ?? ""); + const canRunWithoutAgent = + shouldTrustStructuredTraceSearch && hasExplicitFields && hasExplicitSort; + + // Fetch the org's real environments once: used to ground the agent prompt + // (below) and to flag any requested environment that doesn't exist. Skipped + // only when nothing references an environment — including a structured query + // that skips the agent but puts `environment:` in the query string. + // Seer only translates into the dataset it is given, so it runs only when + // one is explicit. It only sees the natural language query, so skip it for + // structured queries and explicit fields or sort, which the embedded agent + // preserves. Like the UI, an explicit environment is added to Seer's query + // afterwards. + const seerTranslation = + context.experimentalMode && + params.query && + isSeerSearchDataset(params.dataset) && + !hasStructuredQuery && + !hasExplicitFields && + !hasExplicitSort + ? await translateWithSeer({ + apiService, + organizationSlug, + projectId, + dataset: params.dataset, + query: params.query, + }) + : null; + if (seerTranslation && !projectId) { + projectId = "-1"; + } + + if ( + !hasAgentProvider() && + inputDataset !== "replays" && + params.environment && + !canApplyEnvironmentFilter && + !seerTranslation + ) { + throw new UserInputError( + "The `environment` parameter is only supported for dataset='replays'. For other datasets, include environment filtering in the query string instead.", + ); + } + + const willRunAgent = + hasAgentProvider() && !canRunWithoutAgent && !seerTranslation; + const inputReferencesEnvironment = + params.environment != null || + collectRequestedEnvironments(null, params.query ?? "").length > 0; + const environmentNames = + willRunAgent || inputReferencesEnvironment + ? await fetchEnvironmentNames({ + apiService, + organizationSlug, + projectId, + }) + : []; + const knownEnvironments = new Set( + environmentNames.map((name) => name.toLowerCase()), + ); + + if (seerTranslation) { + dataset = inputDataset; + sentryQuery = seerTranslation.query; + fields = seerTranslation.fields; + sortParam = seerTranslation.sort; + // Seer never sees `period`, so an explicit one wins over its time range. + timeParams = hasExplicitPeriod + ? { statsPeriod: params.period } + : seerTranslation.timeParams; + explanation = seerTranslation.explanation; + timeSeries = seerTranslation.timeSeries; + } else if (willRunAgent) { + const parsed = await withProviderFallback({ + operation: "search_events.rewrite", + fallback: () => ({ + dataset: inputDataset, + query: params.query ?? "", + fields: + inputDataset === "replays" + ? [] + : (params.fields ?? defaultFieldsForDataset(inputDataset)), + sort: explicitSort || defaultSortForDataset(inputDataset), + environment: params.environment ?? null, + timeSeries: null, + timeRange: { statsPeriod: params.period ?? "14d" }, + explanation: "", + }), + run: async () => + ( + await searchEventsAgent({ + query: buildAgentPrompt({ + query: params.query, + dataset: inputDataset, + lockDataset, + fields: params.fields, + sort: params.sort, + statsPeriod: params.period, + environment: params.environment, + }), + organizationSlug, + apiService, + projectId, + environmentNames, + }) + ).result, + }); + const shouldTrustExplicitSearchParams = + shouldTrustStructuredTraceSearch || + (hasStructuredQuery && (lockDataset || parsed.dataset === inputDataset)); + + timeSeries = parsed.timeSeries ?? null; + + // Time series requests use yAxis/interval, so sort is not required. + if ( + !timeSeries && + !parsed.sort?.trim() && + !(shouldTrustExplicitSearchParams && hasExplicitSort) + ) { + 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).`, + ); + } + + // Dataset-specific tools never switch datasets, even if the agent asks. + dataset = + lockDataset || shouldTrustStructuredTraceSearch + ? inputDataset + : parsed.dataset; + sentryQuery = shouldTrustStructuredTraceSearch + ? choosePreservingRepairedQuery({ + originalQuery: params.query ?? "", + repairedQuery: parsed.query, + filter: environmentFilter, + }) + : looksLikeSentrySearchSyntax(params.query) && + isSemanticFilterDowngrade(params.query ?? "", parsed.query || "") + ? (params.query ?? "") + : parsed.query || ""; + sortParam = + shouldTrustExplicitSearchParams && explicitSort + ? explicitSort + : parsed.sort?.trim() || defaultSortForDataset(dataset); + explanation = parsed.explanation; + environment = params.environment ?? parsed.environment; + + timeParams = + shouldTrustExplicitSearchParams && hasExplicitPeriod + ? { statsPeriod: params.period } + : (parseAgentTimeRange(parsed.timeRange) ?? { statsPeriod: "14d" }); + + if (dataset === "replays") { + fields = []; + } else { + fields = resolveEventFields({ + dataset, + explicitFields: params.fields, + agentFields: parsed.fields, + trustExplicitFields: shouldTrustExplicitSearchParams, + }); + } + } else { + dataset = inputDataset; + sentryQuery = shouldTrustStructuredTraceSearch + ? explicitStructuredTraceQuery + : (params.query ?? ""); + sortParam = explicitSort || defaultSortForDataset(dataset); + timeParams = { statsPeriod: params.period ?? "14d" }; + fields = + dataset === "replays" + ? [] + : (params.fields ?? defaultFieldsForDataset(dataset)); + } + + // Flag any requested environment that doesn't exist (checking both the + // separate field and `environment:` tokens in the query) so the caller can + // retry with a valid name instead of silently getting zero results. + const unknownEnvironments = + knownEnvironments.size > 0 + ? collectRequestedEnvironments(environment, sentryQuery).filter( + (name) => !knownEnvironments.has(name.toLowerCase()), + ) + : []; + const environmentNote = + unknownEnvironments.length > 0 + ? formatUnknownEnvironmentNote(unknownEnvironments, environmentNames) + : ""; + // The caller chose the project (or the session is scoped to it), so Seer's + // wider scope is only suggested. Scoped sessions can't change the project. + const suggestedProjectIds = context.constraints.projectSlug + ? [] + : (seerTranslation?.suggestedProjectIds ?? []); + const projectSuggestionNote = + suggestedProjectIds.length > 0 + ? `**Note:** Seer suggested also searching project IDs ${suggestedProjectIds.join(", ")}, for example other services in the same trace. Omit \`projectSlug\` to search all accessible projects.` + : ""; + const leadingNote = [ + seerTranslation?.warning, + environmentNote, + projectSuggestionNote, + ] + .filter(Boolean) + .join("\n\n"); + const withLeadingNote = (text: string): string => + leadingNote ? `${leadingNote}\n\n${text}` : text; + + if (dataset === "replays") { + const replaySort = sortParam || DEFAULT_REPLAY_SORT; + if (!isValidReplaySort(replaySort)) { + throw new UserInputError( + `Invalid replay sort "${replaySort}". Use a supported replay sort like ${DEFAULT_REPLAY_SORT}, -count_errors, -count_rage_clicks, or -duration.`, + ); + } + + const replayTimeParams: { + statsPeriod?: string; + start?: string; + end?: string; + } = { ...timeParams }; + if ( + !replayTimeParams.statsPeriod && + !replayTimeParams.start && + !replayTimeParams.end + ) { + replayTimeParams.statsPeriod = DEFAULT_REPLAY_STATS_PERIOD; + } + + const replays = await apiService.searchReplays({ + organizationSlug, + query: sentryQuery, + limit: params.limit, + projectId, + sort: replaySort, + environment: environment ?? undefined, + ...replayTimeParams, + }); + + const replaySearchUrl = apiService.getReplaysSearchUrl(organizationSlug, { + query: sentryQuery || undefined, + projectSlugOrId: projectId, + environment: environment ?? undefined, + sort: replaySort, + ...replayTimeParams, + }); + + getActiveSpan()?.setAttribute( + "gen_ai.tool.call.result.count", + replays.length, + ); + + const replayOutput = formatReplayResults({ + replays, + inputQuery: params.query || sentryQuery || "recent replays", + includeExplanation: params.includeExplanation, + organizationSlug, + apiService, + searchUrl: replaySearchUrl, + replayQuery: sentryQuery, + sort: replaySort, + environment, + explanation, + timeRange: replayTimeParams, + executedSearch: { + dataset, + query: sentryQuery, + fields: [], + sort: replaySort, + timeRange: replayTimeParams, + }, + experimentalMode: context.experimentalMode ?? false, + availableToolNames: context.availableToolNames, + directToolNames: context.directToolNames, + }); + return withLeadingNote(replayOutput); + } + + if (timeSeries) { + const timeSeriesQuery = applyEnvironmentToEventsQuery( + dataset, + sentryQuery, + environment, + ); + // No validateEventsSearch here: it validates the /events/ (discover) + // request shape — fields + orderby — which is not what a timeseries + // sends (yAxis + interval, no fields/sort). events-stats validates the + // query server-side, so a bad query still surfaces as an API error. + const series = await apiService.getEventsTimeSeries({ + organizationSlug, + query: timeSeriesQuery, + yAxis: timeSeries.yAxis, + interval: timeSeries.interval ?? undefined, + projectId, + dataset, + ...timeParams, + }); + const statsUrl = apiService.getEventsExplorerUrl( + organizationSlug, + timeSeriesQuery, + projectId, + dataset, + [timeSeries.yAxis], + `-${timeSeries.yAxis}`, + [timeSeries.yAxis], + [], + timeParams.statsPeriod, + timeParams.start, + timeParams.end, + ); + return withLeadingNote( + formatTimeSeriesResults({ + series, + yAxis: timeSeries.yAxis, + interval: timeSeries.interval, + inputQuery: params.query || timeSeriesQuery, + includeExplanation: params.includeExplanation, + explanation, + timeRange: timeParams, + url: statsUrl, + }), + ); + } + + // Sentry rejects the request if the sort column isn't in the selected + // fields. The embedded agent's schema enforces this, but the handler can + // recombine the caller's explicit fields with a default or explicit sort + // that the agent never saw — so re-check here. + // + // Skip the augment when the sort is non-aggregate but the existing fields + // are aggregate: adding a non-aggregate column to an aggregate query + // changes the GROUP BY and silently corrupts the result. Better to let + // Sentry's 400 propagate so the caller can fix the request explicitly. + // + // Note: fields and sortParam use the same function syntax sent to the API. + fields = augmentFieldsWithSort(fields, sortParam); + + const requestFields = buildRequestFields(dataset, fields); + + // Final gate only. The agent should already have used validateSearch while + // constructing the request; the handler does not run a second repair agent. + sentryQuery = applyEnvironmentToEventsQuery( + dataset, + sentryQuery, + environment, + ); + const lastValidation = await validateEventsSearch(apiService, { + organizationSlug, + dataset, + fields: requestFields, + query: sentryQuery, + sort: sortParam, + projectId, + environment: environment ?? undefined, + ...timeParams, + }); + recordEventsSearchValidationTelemetry({ + attempt: 0, + validation: lastValidation, + }); + + if (!lastValidation.valid) { + const formatted = formatEventsValidationResults(lastValidation); + throw new UserInputError( + formatted + ? `Search validation failed:\n${formatted}` + : "Search validation failed.", + ); + } + + const finalRequestFields = buildRequestFields(dataset, fields); + sentryQuery = applyEnvironmentToEventsQuery( + dataset, + sentryQuery, + environment, + ); + + const eventsResponse = await apiService.searchEvents({ + organizationSlug, + query: sentryQuery, + fields: finalRequestFields, + limit: params.limit, + projectId, + dataset, + sort: sortParam, + crossEventQueries: seerTranslation?.crossEventQueries, + ...timeParams, + }); + + const aggregateFunctions = fields.filter( + (field) => field.includes("(") && field.includes(")"), + ); + const groupByFields = fields.filter( + (field) => !field.includes("(") && !field.includes(")"), + ); + + 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"); + } + + getActiveSpan()?.setAttribute( + "gen_ai.tool.call.result.count", + eventData.length, + ); + + const conversationId = extractConversationIdFromSearchQuery(sentryQuery); + const explorerUrl = conversationId + ? apiService.getAIConversationUrl(organizationSlug, conversationId) + : apiService.getEventsExplorerUrl( + organizationSlug, + sentryQuery, + projectId, + dataset, + fields, + sortParam, + aggregateFunctions, + groupByFields, + timeParams.statsPeriod, + timeParams.start, + timeParams.end, + eventData, + ); + + const formatParams = { + eventData, + inputQuery: params.query || sentryQuery || `${dataset} events`, + includeExplanation: params.includeExplanation, + apiService, + organizationSlug, + explorerUrl, + sentryQuery, + fields, + explanation, + executedSearch: { + dataset, + query: sentryQuery, + fields, + sort: sortParam, + timeRange: timeParams, + }, + experimentalMode: context.experimentalMode ?? false, + availableToolNames: context.availableToolNames, + directToolNames: context.directToolNames, + }; + + switch (dataset) { + case "errors": + return withLeadingNote(formatErrorResults(formatParams)); + case "logs": + return withLeadingNote(formatLogResults(formatParams)); + case "spans": + return withLeadingNote(formatSpanResults(formatParams)); + case "profiles": + return withLeadingNote(formatProfileResults(formatParams)); + default: + return withLeadingNote(formatTraceMetricsResults(formatParams)); + } +} diff --git a/packages/mcp-core/src/tools/support/search-issues/formatters.test.ts b/packages/mcp-core/src/tools/support/search-issues/formatters.test.ts index d91b1f6f4..012d51739 100644 --- a/packages/mcp-core/src/tools/support/search-issues/formatters.test.ts +++ b/packages/mcp-core/src/tools/support/search-issues/formatters.test.ts @@ -327,7 +327,7 @@ describe("formatIssueResults", () => { - Get more details about a specific issue: Use get_sentry_resource with the issue ID or issue URL - Update issue status: Use the Sentry tool \`update_issue\` to resolve or assign issues - - View event counts: Use search_events for aggregated statistics + - View event counts: Use search_errors for aggregated statistics - View feedback details: Use get_sentry_resource to see full feedback content and linked error events " `); diff --git a/packages/mcp-core/src/tools/support/search-issues/formatters.ts b/packages/mcp-core/src/tools/support/search-issues/formatters.ts index 74e80b984..6e1cea6b6 100644 --- a/packages/mcp-core/src/tools/support/search-issues/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-issues/formatters.ts @@ -94,7 +94,7 @@ export function formatIssueResults(params: FormatIssueResultsParams): string { resolvedProtocol, ); - // Add view link with lightweight guidance text (like search_events) + // Add view link with lightweight guidance text (like the search_* event tools) output += `**View these results in Sentry**:\n${searchUrl}\n`; output += `Please tell the user this dashboard link is available if they want to open the results in Sentry.\n\n`; @@ -150,7 +150,7 @@ export function formatIssueResults(params: FormatIssueResultsParams): string { output += "\n"; }); - // Add next steps section (like search_events) + // Add next steps section (like the search_* event tools) output += "## Next Steps\n\n"; output += "- Get more details about a specific issue: Use get_sentry_resource with the issue ID or issue URL\n"; @@ -165,7 +165,7 @@ export function formatIssueResults(params: FormatIssueResultsParams): string { output += `- Update issue status: ${updateIssueInstruction}\n`; } output += - "- View event counts: Use search_events for aggregated statistics\n"; + "- View event counts: Use search_errors for aggregated statistics\n"; // Add feedback-specific guidance if results contain feedback const hasFeedback = issues.some((i) => i.issueCategory === "feedback"); diff --git a/packages/mcp-core/src/tools/surfaces.ts b/packages/mcp-core/src/tools/surfaces.ts index 88dfcd5e6..571459f2f 100644 --- a/packages/mcp-core/src/tools/surfaces.ts +++ b/packages/mcp-core/src/tools/surfaces.ts @@ -18,7 +18,12 @@ export const TOP_LEVEL_TOOL_NAMES = [ "find_organizations", "find_projects", "update_issue", - "search_events", + "search_errors", + "search_logs", + "search_traces", + "search_metrics", + "search_profiles", + "search_replays", "analyze_issue_with_seer", "search_issues", "get_sentry_resource", diff --git a/packages/mcp-server-evals/src/evals/search-events.eval.ts b/packages/mcp-server-evals/src/evals/search-events.eval.ts index fcd49c712..4af9831c1 100644 --- a/packages/mcp-server-evals/src/evals/search-events.eval.ts +++ b/packages/mcp-server-evals/src/evals/search-events.eval.ts @@ -2,7 +2,7 @@ import { describeEval } from "vitest-evals"; import { FIXTURES, NoOpTaskRunner, ToolPredictionScorer } from "./utils"; // Note: This eval requires OPENROUTER_API_KEY to be set in the environment -// The search_events tool uses the AI SDK to translate natural language queries +// The dataset search tools (search_errors, search_traces, search_logs, ...) use the AI SDK to translate natural language queries describeEval("search-events", { data: async () => { return [ @@ -15,11 +15,10 @@ describeEval("search-events", { arguments: {}, }, { - name: "search_events", + name: "search_errors", arguments: { organizationSlug: FIXTURES.organizationSlug, query: "database timeouts from the last week", - dataset: "errors", }, }, ], @@ -33,11 +32,10 @@ describeEval("search-events", { arguments: {}, }, { - name: "search_events", + name: "search_traces", arguments: { organizationSlug: FIXTURES.organizationSlug, query: "slow API calls taking over 5 seconds", - dataset: "spans", }, }, ], @@ -51,11 +49,10 @@ describeEval("search-events", { arguments: {}, }, { - name: "search_events", + name: "search_logs", arguments: { organizationSlug: FIXTURES.organizationSlug, query: "error logs from the last hour", - dataset: "logs", }, }, ], @@ -69,12 +66,11 @@ describeEval("search-events", { arguments: {}, }, { - name: "search_events", + name: "search_errors", arguments: { organizationSlug: FIXTURES.organizationSlug, projectSlug: FIXTURES.projectSlug, query: "authentication errors", - dataset: "errors", }, }, ], @@ -92,11 +88,10 @@ describeEval("search-events", { arguments: {}, }, { - name: "search_events", + name: "search_errors", arguments: { organizationSlug: FIXTURES.organizationSlug, query: "errors affecting user.id:12345", - dataset: "errors", }, }, ], diff --git a/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts b/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts index e626b7f38..c3e7dbc12 100644 --- a/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts +++ b/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts @@ -106,7 +106,7 @@ Consider: 1. Match the expected tool sequence exactly - the expected tools show realistic AI behavior 2. When a value like "sentry-mcp-evals" appears alone, it's typically an organizationSlug, not a projectSlug 3. Arguments should match expected values (organizationSlug, projectSlug, name, etc.) -4. For natural language queries in search_events, exact phrasing doesn't need to match +4. For natural language queries in search tools (search_errors, search_traces, search_logs, etc.), exact phrasing doesn't need to match 5. Extra parameters like regionUrl are acceptable 6. The AI commonly does discovery calls even when slugs appear to be provided, to get region info diff --git a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md index b609d15d4..37705a4f0 100644 --- a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md +++ b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md @@ -15,9 +15,14 @@ allowedTools: - find_organizations - find_projects - get_sentry_resource - - search_events + - search_errors - search_issues + - search_logs + - search_metrics + - search_profiles + - search_replays - search_sentry_tools + - search_traces --- You are a Sentry expert. Investigate errors, analyze performance, and manage projects using the available MCP tools. @@ -34,15 +39,15 @@ You are a Sentry expert. Investigate errors, analyze performance, and manage pro ## Key Tool Distinctions -- `search_issues` returns grouped issue lists. `search_events` returns counts, aggregations, or individual event rows. +- `search_issues` returns grouped issue lists. The dataset search tools return counts, aggregations, or individual rows: `search_errors` (exceptions/crashes), `search_traces` (spans, requests, latency, and spans whose trace also has a matching log or metric), `search_logs`, `search_metrics`, `search_profiles`, and `search_replays`. - `get_sentry_resource` fetches a known issue, event, trace, span, replay, or generic Sentry resource from a URL or resource ID. It also routes supported profile URLs to profile details. Use the catalog tool `get_issue_breadcrumbs` for an issue's breadcrumb trail. `analyze_issue_with_seer` provides AI root cause analysis with code fixes. - Snapshot tools such as `get_snapshot`, `get_snapshot_image`, and `get_latest_base_snapshot` are catalog tools. Use `search_sentry_tools` only when you need to inspect their schemas. - Use `get_snapshot` for a preprod snapshot diff summary from `organizationSlug` + `snapshotId`. For snapshot URLs, use `get_sentry_resource` instead. - Use `get_snapshot_image` for metadata and preview/full image content for one snapshot image. Use the exact `image_file_name` from `get_snapshot` as `imageIdentifier`. - When asked for screenshots, screens, golden images, reference images, dark/light mode visuals, or to list available snapshots for an app, use `get_latest_base_snapshot` with the `appId` parameter. This is not an event or issue search operation. -- `search_events` and `search_issues` accept `query` as natural language or direct Sentry search syntax; when an agent is configured, it repairs the query and related params before running. For issue-scoped event searches, use `search_issue_events`. -- Agent conversations are spans grouped by `gen_ai.conversation.id` — they are NOT issues. Use `search_agent_conversations` to find or list conversations, and use `get_sentry_resource(resourceType='ai_conversation')` for a specific conversation. Use `search_events` with `dataset='spans'` only for raw span-level telemetry follow-up. -- Trace responses from `get_sentry_resource` are condensed overviews by default. Use `resourceType='span'` with `resourceId=':'` or a trace URL with `?node=span-` to focus one span directly; otherwise, if the trace output says it shows a subset of spans and the user needs more detail, follow up with `search_events` on that trace. +- The dataset search tools and `search_issues` accept `query` as natural language or direct Sentry search syntax; when an agent is configured, it repairs the query and related params before running. For issue-scoped event searches, use `search_issue_events`. +- Agent conversations are spans grouped by `gen_ai.conversation.id` — they are NOT issues. Use `search_agent_conversations` to find or list conversations, and use `get_sentry_resource(resourceType='ai_conversation')` for a specific conversation. Use `search_traces` only for raw span-level telemetry follow-up. +- Trace responses from `get_sentry_resource` are condensed overviews by default. Use `resourceType='span'` with `resourceId=':'` or a trace URL with `?node=span-` to focus one span directly; otherwise, if the trace output says it shows a subset of spans and the user needs more detail, follow up with `search_traces` on that trace. ## Output diff --git a/plugins/sentry-mcp/agents/sentry-mcp.md b/plugins/sentry-mcp/agents/sentry-mcp.md index b609d15d4..37705a4f0 100644 --- a/plugins/sentry-mcp/agents/sentry-mcp.md +++ b/plugins/sentry-mcp/agents/sentry-mcp.md @@ -15,9 +15,14 @@ allowedTools: - find_organizations - find_projects - get_sentry_resource - - search_events + - search_errors - search_issues + - search_logs + - search_metrics + - search_profiles + - search_replays - search_sentry_tools + - search_traces --- You are a Sentry expert. Investigate errors, analyze performance, and manage projects using the available MCP tools. @@ -34,15 +39,15 @@ You are a Sentry expert. Investigate errors, analyze performance, and manage pro ## Key Tool Distinctions -- `search_issues` returns grouped issue lists. `search_events` returns counts, aggregations, or individual event rows. +- `search_issues` returns grouped issue lists. The dataset search tools return counts, aggregations, or individual rows: `search_errors` (exceptions/crashes), `search_traces` (spans, requests, latency, and spans whose trace also has a matching log or metric), `search_logs`, `search_metrics`, `search_profiles`, and `search_replays`. - `get_sentry_resource` fetches a known issue, event, trace, span, replay, or generic Sentry resource from a URL or resource ID. It also routes supported profile URLs to profile details. Use the catalog tool `get_issue_breadcrumbs` for an issue's breadcrumb trail. `analyze_issue_with_seer` provides AI root cause analysis with code fixes. - Snapshot tools such as `get_snapshot`, `get_snapshot_image`, and `get_latest_base_snapshot` are catalog tools. Use `search_sentry_tools` only when you need to inspect their schemas. - Use `get_snapshot` for a preprod snapshot diff summary from `organizationSlug` + `snapshotId`. For snapshot URLs, use `get_sentry_resource` instead. - Use `get_snapshot_image` for metadata and preview/full image content for one snapshot image. Use the exact `image_file_name` from `get_snapshot` as `imageIdentifier`. - When asked for screenshots, screens, golden images, reference images, dark/light mode visuals, or to list available snapshots for an app, use `get_latest_base_snapshot` with the `appId` parameter. This is not an event or issue search operation. -- `search_events` and `search_issues` accept `query` as natural language or direct Sentry search syntax; when an agent is configured, it repairs the query and related params before running. For issue-scoped event searches, use `search_issue_events`. -- Agent conversations are spans grouped by `gen_ai.conversation.id` — they are NOT issues. Use `search_agent_conversations` to find or list conversations, and use `get_sentry_resource(resourceType='ai_conversation')` for a specific conversation. Use `search_events` with `dataset='spans'` only for raw span-level telemetry follow-up. -- Trace responses from `get_sentry_resource` are condensed overviews by default. Use `resourceType='span'` with `resourceId=':'` or a trace URL with `?node=span-` to focus one span directly; otherwise, if the trace output says it shows a subset of spans and the user needs more detail, follow up with `search_events` on that trace. +- The dataset search tools and `search_issues` accept `query` as natural language or direct Sentry search syntax; when an agent is configured, it repairs the query and related params before running. For issue-scoped event searches, use `search_issue_events`. +- Agent conversations are spans grouped by `gen_ai.conversation.id` — they are NOT issues. Use `search_agent_conversations` to find or list conversations, and use `get_sentry_resource(resourceType='ai_conversation')` for a specific conversation. Use `search_traces` only for raw span-level telemetry follow-up. +- Trace responses from `get_sentry_resource` are condensed overviews by default. Use `resourceType='span'` with `resourceId=':'` or a trace URL with `?node=span-` to focus one span directly; otherwise, if the trace output says it shows a subset of spans and the user needs more detail, follow up with `search_traces` on that trace. ## Output From b63856ad9ce6b40c0d18e45e5d108dfb0525ac72 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:13:12 +0000 Subject: [PATCH 2/2] test(search): Slim down dataset search tool tests Replace the six copy-pasted per-tool test files (each mocking the AI SDK and MSW endpoints) with one-test baselines that mock only runSearchEvents via a shared test-utils helper. The lockDataset behavior is tested once against the shared handler in search-events.test.ts, reusing its existing mocks. Also drop the "not search_replays or search_issues" clause from the get_latest_base_snapshot description to save tokens. --- packages/mcp-core/src/skillDefinitions.json | 2 +- .../src/test-utils/dataset-search-tool.ts | 37 +++ packages/mcp-core/src/toolDefinitions.json | 2 +- .../tools/catalog/get-latest-base-snapshot.ts | 2 +- .../src/tools/catalog/search-errors.test.ts | 136 ++--------- .../src/tools/catalog/search-events.test.ts | 46 ++++ .../src/tools/catalog/search-logs.test.ts | 136 ++--------- .../src/tools/catalog/search-metrics.test.ts | 136 ++--------- .../src/tools/catalog/search-profiles.test.ts | 138 ++--------- .../src/tools/catalog/search-replays.test.ts | 141 ++---------- .../src/tools/catalog/search-traces.test.ts | 214 ++---------------- 11 files changed, 196 insertions(+), 794 deletions(-) create mode 100644 packages/mcp-core/src/test-utils/dataset-search-tool.ts diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 3f7636d28..44b41ac87 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -114,7 +114,7 @@ }, { "name": "get_latest_base_snapshot", - "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots — not search_replays or search_issues.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", + "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", "requiredScopes": ["project:read"] }, { diff --git a/packages/mcp-core/src/test-utils/dataset-search-tool.ts b/packages/mcp-core/src/test-utils/dataset-search-tool.ts new file mode 100644 index 000000000..abf122ba9 --- /dev/null +++ b/packages/mcp-core/src/test-utils/dataset-search-tool.ts @@ -0,0 +1,37 @@ +import { vi } from "vitest"; +import { runSearchEvents } from "../tools/support/search-events/search"; +import type { ServerContext } from "../types"; +import { createTestContext } from "./context"; + +const params = { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "anything", + limit: 10, + includeExplanation: false, +}; + +/** + * Call a dataset-specific search tool and report what it passed to the shared + * handler. The test file must mock `runSearchEvents`: + * + * vi.mock("../support/search-events/search", async (importOriginal) => ({ + * ...(await importOriginal()), + * runSearchEvents: vi.fn(async () => "ok"), + * })); + */ +export async function inspectDatasetSearchTool

(tool: { + inputSchema: object; + handler(params: P, context: ServerContext): Promise; +}) { + const run = vi.mocked(runSearchEvents); + run.mockClear(); + await tool.handler(params as P, createTestContext()); + const [handlerParams, , options] = run.mock.calls[0] ?? []; + return { + dataset: handlerParams?.dataset, + options, + inputParams: Object.keys(tool.inputSchema), + }; +} diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index cab05bc0e..9fa64538c 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -5181,7 +5181,7 @@ }, { "name": "get_latest_base_snapshot", - "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots — not search_replays or search_issues.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", + "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts b/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts index 8c8a884b1..19b02d7db 100644 --- a/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts +++ b/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts @@ -15,7 +15,7 @@ export default defineTool({ description: [ "Get the latest UI screenshots/images for an app from the preprod snapshot system.", "", - "This is the primary tool for retrieving app screenshots — not search_replays or search_issues.", + "This is the primary tool for retrieving app screenshots.", "", "Use this tool when you need to:", "- Get screenshots, screens, golden images, or reference images for an app", diff --git a/packages/mcp-core/src/tools/catalog/search-errors.test.ts b/packages/mcp-core/src/tools/catalog/search-errors.test.ts index 891a10894..bf26747da 100644 --- a/packages/mcp-core/src/tools/catalog/search-errors.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-errors.test.ts @@ -1,78 +1,18 @@ -import { mswServer } from "@sentry/mcp-server-mocks"; -import { generateText } from "ai"; -import { HttpResponse, http } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; +import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool"; import searchErrors from "./search-errors"; -vi.mock("@ai-sdk/openai", () => { - const mockModel = vi.fn(() => "mocked-model"); - return { - openai: mockModel, - createOpenAI: vi.fn(() => mockModel), - }; -}); - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateText: vi.fn(), - tool: vi.fn(() => ({ execute: vi.fn() })), - Output: { object: vi.fn(() => ({})) }, - }; -}); - -const context = { - constraints: { - organizationSlug: null, - regionUrl: null, - projectSlug: null, - }, - accessToken: "test-token", - userId: "1", -}; - -function agentResponse(output: Record) { - return { - text: JSON.stringify(output), - experimental_output: output, - finishReason: "stop" as const, - usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, - warnings: [] as const, - } as any; -} - -describe("search_errors", () => { - const mockGenerateText = vi.mocked(generateText); - - beforeEach(() => { - vi.clearAllMocks(); - process.env.OPENAI_API_KEY = "test-key"; - process.env.OPENROUTER_API_KEY = ""; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/environments/", - () => HttpResponse.json([]), - ), - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", - () => - HttpResponse.json({ - valid: true, - projects: [], - dataset: [], - environment: [], - field: [], - query: { valid: true, error: null, fields: [] }, - orderby: [], - }), - ), - ); - }); - - it("takes its dataset from the tool instead of a parameter", () => { - expect(Object.keys(searchErrors.inputSchema)).toMatchInlineSnapshot(` - [ +// The shared handler is covered by search-events.test.ts. +vi.mock("../support/search-events/search", async (importOriginal) => ({ + ...(await importOriginal()), + runSearchEvents: vi.fn(async () => "ok"), +})); + +it("search_errors runs the shared handler locked to errors", async () => { + expect(await inspectDatasetSearchTool(searchErrors)).toMatchInlineSnapshot(` + { + "dataset": "errors", + "inputParams": [ "organizationSlug", "query", "fields", @@ -82,50 +22,10 @@ describe("search_errors", () => { "regionUrl", "limit", "includeExplanation", - ] - `); - }); - - it("keeps the errors dataset when the agent suggests another", async () => { - mockGenerateText.mockResolvedValue( - agentResponse({ - dataset: "logs", - query: "level:error", - fields: ["timestamp", "message"], - sort: "-timestamp", - environment: null, - timeRange: { statsPeriod: "24h" }, - explanation: "Test query translation", - }), - ); - const requestedDatasets: Array = []; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/test-org/events/", - ({ request }) => { - requestedDatasets.push( - new URL(request.url).searchParams.get("dataset"), - ); - return HttpResponse.json({ data: [] }); - }, - ), - ); - - await searchErrors.handler( - { - organizationSlug: "test-org", - regionUrl: null, - projectSlug: null, - query: "how many errors today", - limit: 10, - includeExplanation: false, + ], + "options": { + "lockDataset": true, }, - context, - ); - - expect(requestedDatasets).toEqual(["errors"]); - expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( - "The dataset is fixed to errors", - ); - }); + } + `); }); diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index b3cfb3f40..7de937048 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3,6 +3,7 @@ import { APICallError, generateText } from "ai"; import { HttpResponse, http } from "msw"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { UserInputError } from "../../errors"; +import { runSearchEvents } from "../support/search-events/search"; import searchEvents from "./search-events"; // Mock the AI SDK @@ -3339,6 +3340,51 @@ describe("search_events", () => { expect(mockGenerateText).not.toHaveBeenCalled(); }); + it("keeps the caller's dataset when lockDataset is set", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("logs", "level:error"), + ); + const requestedDatasets: Array = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + requestedDatasets.push( + new URL(request.url).searchParams.get("dataset"), + ); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await runSearchEvents( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "how many errors today", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + { lockDataset: true }, + ); + + expect(requestedDatasets).toEqual(["errors"]); + expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( + "The dataset is fixed to errors", + ); + }); + describe("with Seer", () => { const seerParams = { organizationSlug: "test-org", diff --git a/packages/mcp-core/src/tools/catalog/search-logs.test.ts b/packages/mcp-core/src/tools/catalog/search-logs.test.ts index 69145964a..64a4aee3d 100644 --- a/packages/mcp-core/src/tools/catalog/search-logs.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-logs.test.ts @@ -1,78 +1,18 @@ -import { mswServer } from "@sentry/mcp-server-mocks"; -import { generateText } from "ai"; -import { HttpResponse, http } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; +import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool"; import searchLogs from "./search-logs"; -vi.mock("@ai-sdk/openai", () => { - const mockModel = vi.fn(() => "mocked-model"); - return { - openai: mockModel, - createOpenAI: vi.fn(() => mockModel), - }; -}); - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateText: vi.fn(), - tool: vi.fn(() => ({ execute: vi.fn() })), - Output: { object: vi.fn(() => ({})) }, - }; -}); - -const context = { - constraints: { - organizationSlug: null, - regionUrl: null, - projectSlug: null, - }, - accessToken: "test-token", - userId: "1", -}; - -function agentResponse(output: Record) { - return { - text: JSON.stringify(output), - experimental_output: output, - finishReason: "stop" as const, - usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, - warnings: [] as const, - } as any; -} - -describe("search_logs", () => { - const mockGenerateText = vi.mocked(generateText); - - beforeEach(() => { - vi.clearAllMocks(); - process.env.OPENAI_API_KEY = "test-key"; - process.env.OPENROUTER_API_KEY = ""; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/environments/", - () => HttpResponse.json([]), - ), - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", - () => - HttpResponse.json({ - valid: true, - projects: [], - dataset: [], - environment: [], - field: [], - query: { valid: true, error: null, fields: [] }, - orderby: [], - }), - ), - ); - }); - - it("takes its dataset from the tool instead of a parameter", () => { - expect(Object.keys(searchLogs.inputSchema)).toMatchInlineSnapshot(` - [ +// The shared handler is covered by search-events.test.ts. +vi.mock("../support/search-events/search", async (importOriginal) => ({ + ...(await importOriginal()), + runSearchEvents: vi.fn(async () => "ok"), +})); + +it("search_logs runs the shared handler locked to logs", async () => { + expect(await inspectDatasetSearchTool(searchLogs)).toMatchInlineSnapshot(` + { + "dataset": "logs", + "inputParams": [ "organizationSlug", "query", "fields", @@ -82,50 +22,10 @@ describe("search_logs", () => { "regionUrl", "limit", "includeExplanation", - ] - `); - }); - - it("keeps the logs dataset when the agent suggests another", async () => { - mockGenerateText.mockResolvedValue( - agentResponse({ - dataset: "errors", - query: "severity:error", - fields: ["timestamp", "message", "severity"], - sort: "-timestamp", - environment: null, - timeRange: { statsPeriod: "24h" }, - explanation: "Test query translation", - }), - ); - const requestedDatasets: Array = []; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/test-org/events/", - ({ request }) => { - requestedDatasets.push( - new URL(request.url).searchParams.get("dataset"), - ); - return HttpResponse.json({ data: [] }); - }, - ), - ); - - await searchLogs.handler( - { - organizationSlug: "test-org", - regionUrl: null, - projectSlug: null, - query: "error logs from the last hour", - limit: 10, - includeExplanation: false, + ], + "options": { + "lockDataset": true, }, - context, - ); - - expect(requestedDatasets).toEqual(["logs"]); - expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( - "The dataset is fixed to logs", - ); - }); + } + `); }); diff --git a/packages/mcp-core/src/tools/catalog/search-metrics.test.ts b/packages/mcp-core/src/tools/catalog/search-metrics.test.ts index 0a7e9fd4a..6e6774974 100644 --- a/packages/mcp-core/src/tools/catalog/search-metrics.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-metrics.test.ts @@ -1,78 +1,18 @@ -import { mswServer } from "@sentry/mcp-server-mocks"; -import { generateText } from "ai"; -import { HttpResponse, http } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; +import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool"; import searchMetrics from "./search-metrics"; -vi.mock("@ai-sdk/openai", () => { - const mockModel = vi.fn(() => "mocked-model"); - return { - openai: mockModel, - createOpenAI: vi.fn(() => mockModel), - }; -}); - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateText: vi.fn(), - tool: vi.fn(() => ({ execute: vi.fn() })), - Output: { object: vi.fn(() => ({})) }, - }; -}); - -const context = { - constraints: { - organizationSlug: null, - regionUrl: null, - projectSlug: null, - }, - accessToken: "test-token", - userId: "1", -}; - -function agentResponse(output: Record) { - return { - text: JSON.stringify(output), - experimental_output: output, - finishReason: "stop" as const, - usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, - warnings: [] as const, - } as any; -} - -describe("search_metrics", () => { - const mockGenerateText = vi.mocked(generateText); - - beforeEach(() => { - vi.clearAllMocks(); - process.env.OPENAI_API_KEY = "test-key"; - process.env.OPENROUTER_API_KEY = ""; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/environments/", - () => HttpResponse.json([]), - ), - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", - () => - HttpResponse.json({ - valid: true, - projects: [], - dataset: [], - environment: [], - field: [], - query: { valid: true, error: null, fields: [] }, - orderby: [], - }), - ), - ); - }); - - it("takes its dataset from the tool instead of a parameter", () => { - expect(Object.keys(searchMetrics.inputSchema)).toMatchInlineSnapshot(` - [ +// The shared handler is covered by search-events.test.ts. +vi.mock("../support/search-events/search", async (importOriginal) => ({ + ...(await importOriginal()), + runSearchEvents: vi.fn(async () => "ok"), +})); + +it("search_metrics runs the shared handler locked to metrics", async () => { + expect(await inspectDatasetSearchTool(searchMetrics)).toMatchInlineSnapshot(` + { + "dataset": "metrics", + "inputParams": [ "organizationSlug", "query", "fields", @@ -82,50 +22,10 @@ describe("search_metrics", () => { "regionUrl", "limit", "includeExplanation", - ] - `); - }); - - it("keeps the metrics dataset when the agent suggests another", async () => { - mockGenerateText.mockResolvedValue( - agentResponse({ - dataset: "spans", - query: "metric.name:http.request.duration", - fields: ["timestamp", "metric.name", "value"], - sort: "-timestamp", - environment: null, - timeRange: { statsPeriod: "24h" }, - explanation: "Test query translation", - }), - ); - const requestedDatasets: Array = []; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/test-org/events/", - ({ request }) => { - requestedDatasets.push( - new URL(request.url).searchParams.get("dataset"), - ); - return HttpResponse.json({ data: [] }); - }, - ), - ); - - await searchMetrics.handler( - { - organizationSlug: "test-org", - regionUrl: null, - projectSlug: null, - query: "recent request duration metrics", - limit: 10, - includeExplanation: false, + ], + "options": { + "lockDataset": true, }, - context, - ); - - expect(requestedDatasets).toEqual(["tracemetrics"]); - expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( - "The dataset is fixed to metrics", - ); - }); + } + `); }); diff --git a/packages/mcp-core/src/tools/catalog/search-profiles.test.ts b/packages/mcp-core/src/tools/catalog/search-profiles.test.ts index f3219e916..91e6827b5 100644 --- a/packages/mcp-core/src/tools/catalog/search-profiles.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-profiles.test.ts @@ -1,78 +1,20 @@ -import { mswServer } from "@sentry/mcp-server-mocks"; -import { generateText } from "ai"; -import { HttpResponse, http } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; +import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool"; import searchProfiles from "./search-profiles"; -vi.mock("@ai-sdk/openai", () => { - const mockModel = vi.fn(() => "mocked-model"); - return { - openai: mockModel, - createOpenAI: vi.fn(() => mockModel), - }; -}); - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateText: vi.fn(), - tool: vi.fn(() => ({ execute: vi.fn() })), - Output: { object: vi.fn(() => ({})) }, - }; -}); - -const context = { - constraints: { - organizationSlug: null, - regionUrl: null, - projectSlug: null, - }, - accessToken: "test-token", - userId: "1", -}; - -function agentResponse(output: Record) { - return { - text: JSON.stringify(output), - experimental_output: output, - finishReason: "stop" as const, - usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, - warnings: [] as const, - } as any; -} - -describe("search_profiles", () => { - const mockGenerateText = vi.mocked(generateText); - - beforeEach(() => { - vi.clearAllMocks(); - process.env.OPENAI_API_KEY = "test-key"; - process.env.OPENROUTER_API_KEY = ""; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/environments/", - () => HttpResponse.json([]), - ), - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", - () => - HttpResponse.json({ - valid: true, - projects: [], - dataset: [], - environment: [], - field: [], - query: { valid: true, error: null, fields: [] }, - orderby: [], - }), - ), - ); - }); - - it("takes its dataset from the tool instead of a parameter", () => { - expect(Object.keys(searchProfiles.inputSchema)).toMatchInlineSnapshot(` - [ +// The shared handler is covered by search-events.test.ts. +vi.mock("../support/search-events/search", async (importOriginal) => ({ + ...(await importOriginal()), + runSearchEvents: vi.fn(async () => "ok"), +})); + +it("search_profiles runs the shared handler locked to profiles", async () => { + expect( + await inspectDatasetSearchTool(searchProfiles), + ).toMatchInlineSnapshot(` + { + "dataset": "profiles", + "inputParams": [ "organizationSlug", "query", "fields", @@ -82,50 +24,10 @@ describe("search_profiles", () => { "regionUrl", "limit", "includeExplanation", - ] - `); - }); - - it("keeps the profiles dataset when the agent suggests another", async () => { - mockGenerateText.mockResolvedValue( - agentResponse({ - dataset: "spans", - query: "transaction:/checkout", - fields: ["profile.id", "transaction", "timestamp"], - sort: "-timestamp", - environment: null, - timeRange: { statsPeriod: "24h" }, - explanation: "Test query translation", - }), - ); - const requestedDatasets: Array = []; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/test-org/events/", - ({ request }) => { - requestedDatasets.push( - new URL(request.url).searchParams.get("dataset"), - ); - return HttpResponse.json({ data: [] }); - }, - ), - ); - - await searchProfiles.handler( - { - organizationSlug: "test-org", - regionUrl: null, - projectSlug: null, - query: "recent checkout profiles", - limit: 10, - includeExplanation: false, + ], + "options": { + "lockDataset": true, }, - context, - ); - - expect(requestedDatasets).toEqual(["profiles"]); - expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( - "The dataset is fixed to profiles", - ); - }); + } + `); }); diff --git a/packages/mcp-core/src/tools/catalog/search-replays.test.ts b/packages/mcp-core/src/tools/catalog/search-replays.test.ts index 3f3b998b3..e14b017f4 100644 --- a/packages/mcp-core/src/tools/catalog/search-replays.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-replays.test.ts @@ -1,78 +1,18 @@ -import { mswServer } from "@sentry/mcp-server-mocks"; -import { generateText } from "ai"; -import { HttpResponse, http } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; +import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool"; import searchReplays from "./search-replays"; -vi.mock("@ai-sdk/openai", () => { - const mockModel = vi.fn(() => "mocked-model"); - return { - openai: mockModel, - createOpenAI: vi.fn(() => mockModel), - }; -}); - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateText: vi.fn(), - tool: vi.fn(() => ({ execute: vi.fn() })), - Output: { object: vi.fn(() => ({})) }, - }; -}); - -const context = { - constraints: { - organizationSlug: null, - regionUrl: null, - projectSlug: null, - }, - accessToken: "test-token", - userId: "1", -}; - -function agentResponse(output: Record) { - return { - text: JSON.stringify(output), - experimental_output: output, - finishReason: "stop" as const, - usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, - warnings: [] as const, - } as any; -} - -describe("search_replays", () => { - const mockGenerateText = vi.mocked(generateText); - - beforeEach(() => { - vi.clearAllMocks(); - process.env.OPENAI_API_KEY = "test-key"; - process.env.OPENROUTER_API_KEY = ""; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/environments/", - () => HttpResponse.json([]), - ), - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", - () => - HttpResponse.json({ - valid: true, - projects: [], - dataset: [], - environment: [], - field: [], - query: { valid: true, error: null, fields: [] }, - orderby: [], - }), - ), - ); - }); - - it("takes its dataset from the tool instead of a parameter", () => { - expect(Object.keys(searchReplays.inputSchema)).toMatchInlineSnapshot(` - [ +// The shared handler is covered by search-events.test.ts. +vi.mock("../support/search-events/search", async (importOriginal) => ({ + ...(await importOriginal()), + runSearchEvents: vi.fn(async () => "ok"), +})); + +it("search_replays runs the shared handler locked to replays", async () => { + expect(await inspectDatasetSearchTool(searchReplays)).toMatchInlineSnapshot(` + { + "dataset": "replays", + "inputParams": [ "organizationSlug", "query", "sort", @@ -82,55 +22,10 @@ describe("search_replays", () => { "regionUrl", "limit", "includeExplanation", - ] - `); - }); - - it("keeps the replays dataset when the agent suggests another", async () => { - mockGenerateText.mockResolvedValue( - agentResponse({ - dataset: "errors", - query: "count_errors:>0", - fields: [], - sort: "-count_errors", - environment: null, - timeRange: { statsPeriod: "24h" }, - explanation: "Test query translation", - }), - ); - const requestedPaths: string[] = []; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/test-org/replays/", - ({ request }) => { - requestedPaths.push(new URL(request.url).pathname); - return HttpResponse.json({ data: [] }); - }, - ), - http.get( - "https://sentry.io/api/0/organizations/test-org/events/", - ({ request }) => { - requestedPaths.push(new URL(request.url).pathname); - return HttpResponse.json({ data: [] }); - }, - ), - ); - - await searchReplays.handler( - { - organizationSlug: "test-org", - regionUrl: null, - projectSlug: null, - query: "replays with errors in the last day", - limit: 10, - includeExplanation: false, + ], + "options": { + "lockDataset": true, }, - context, - ); - - expect(requestedPaths).toEqual(["/api/0/organizations/test-org/replays/"]); - expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( - "The dataset is fixed to replays", - ); - }); + } + `); }); diff --git a/packages/mcp-core/src/tools/catalog/search-traces.test.ts b/packages/mcp-core/src/tools/catalog/search-traces.test.ts index 9da35968e..c90403b86 100644 --- a/packages/mcp-core/src/tools/catalog/search-traces.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-traces.test.ts @@ -1,78 +1,18 @@ -import { mswServer } from "@sentry/mcp-server-mocks"; -import { generateText } from "ai"; -import { HttpResponse, http } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; +import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool"; import searchTraces from "./search-traces"; -vi.mock("@ai-sdk/openai", () => { - const mockModel = vi.fn(() => "mocked-model"); - return { - openai: mockModel, - createOpenAI: vi.fn(() => mockModel), - }; -}); - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateText: vi.fn(), - tool: vi.fn(() => ({ execute: vi.fn() })), - Output: { object: vi.fn(() => ({})) }, - }; -}); - -const context = { - constraints: { - organizationSlug: null, - regionUrl: null, - projectSlug: null, - }, - accessToken: "test-token", - userId: "1", -}; - -function agentResponse(output: Record) { - return { - text: JSON.stringify(output), - experimental_output: output, - finishReason: "stop" as const, - usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, - warnings: [] as const, - } as any; -} - -describe("search_traces", () => { - const mockGenerateText = vi.mocked(generateText); - - beforeEach(() => { - vi.clearAllMocks(); - process.env.OPENAI_API_KEY = "test-key"; - process.env.OPENROUTER_API_KEY = ""; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/environments/", - () => HttpResponse.json([]), - ), - http.get( - "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", - () => - HttpResponse.json({ - valid: true, - projects: [], - dataset: [], - environment: [], - field: [], - query: { valid: true, error: null, fields: [] }, - orderby: [], - }), - ), - ); - }); - - it("takes its dataset from the tool instead of a parameter", () => { - expect(Object.keys(searchTraces.inputSchema)).toMatchInlineSnapshot(` - [ +// The shared handler is covered by search-events.test.ts. +vi.mock("../support/search-events/search", async (importOriginal) => ({ + ...(await importOriginal()), + runSearchEvents: vi.fn(async () => "ok"), +})); + +it("search_traces runs the shared handler locked to spans", async () => { + expect(await inspectDatasetSearchTool(searchTraces)).toMatchInlineSnapshot(` + { + "dataset": "spans", + "inputParams": [ "organizationSlug", "query", "fields", @@ -82,128 +22,10 @@ describe("search_traces", () => { "regionUrl", "limit", "includeExplanation", - ] - `); - }); - - it("keeps the spans dataset when the agent suggests another", async () => { - mockGenerateText.mockResolvedValue( - agentResponse({ - dataset: "logs", - query: "span.op:db", - fields: ["span.op", "span.duration", "timestamp"], - sort: "-timestamp", - environment: null, - timeRange: { statsPeriod: "24h" }, - explanation: "Test query translation", - }), - ); - const requestedDatasets: Array = []; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/test-org/events/", - ({ request }) => { - requestedDatasets.push( - new URL(request.url).searchParams.get("dataset"), - ); - return HttpResponse.json({ data: [] }); - }, - ), - ); - - await searchTraces.handler( - { - organizationSlug: "test-org", - regionUrl: null, - projectSlug: null, - query: "slow db queries", - limit: 10, - includeExplanation: false, - }, - context, - ); - - expect(requestedDatasets).toEqual(["spans"]); - expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain( - "The dataset is fixed to spans", - ); - }); - - it("sends natural language queries to Seer's Traces strategy", async () => { - const seerStartBodies: unknown[] = []; - mswServer.use( - http.get("https://sentry.io/api/0/organizations/test-org/", () => - HttpResponse.json({ - id: "1", - slug: "test-org", - name: "Test Org", - features: ["gen-ai-search-agent-translate"], - hideAiFeatures: false, - }), - ), - http.post( - "https://sentry.io/api/0/organizations/test-org/search-agent/start/", - async ({ request }) => { - seerStartBodies.push(await request.json()); - return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); - }, - ), - http.get( - "https://sentry.io/api/0/organizations/test-org/search-agent/state/run-uuid/", - () => - HttpResponse.json({ - sentry_run_id: "run-uuid", - session: { - status: "completed", - final_response: { - responses: [ - { - query: "span.op:http.client", - group_by: [], - visualization: [], - sort: "-span.duration", - stats_period: "24h", - start: null, - end: null, - mode: "samples", - }, - ], - unsupported_reason: null, - }, - }, - }), - ), - http.get( - "https://sentry.io/api/0/organizations/test-org/events/", - ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("dataset")).toBe("spans"); - expect(url.searchParams.get("query")).toBe("span.op:http.client"); - return HttpResponse.json({ data: [] }); - }, - ), - ); - - const result = await searchTraces.handler( - { - organizationSlug: "test-org", - regionUrl: null, - projectSlug: null, - query: "slowest api calls in the last 24 hours", - limit: 10, - includeExplanation: true, - }, - { ...context, experimentalMode: true }, - ); - - expect(seerStartBodies).toEqual([ - { - project_ids: [-1], - natural_language_query: "slowest api calls in the last 24 hours", - strategy: "Traces", + ], + "options": { + "lockDataset": true, }, - ]); - expect(mockGenerateText).not.toHaveBeenCalled(); - expect(result).toContain("Translated by Seer's search agent."); - }); + } + `); });