From 8b530756731cf3dedac6aa2c0fdac8d9fc2b2413 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 12:44:16 -0400 Subject: [PATCH 01/19] feat(search-events): Translate natural language queries with Seer Use Seer's search agent to translate natural language queries in search_events when the organization has Seer enabled, and fall back to the embedded agent or direct query syntax when it doesn't. Seer is used when a query, projectSlug, and an errors/logs/spans/metrics dataset are given and the organization exposes the gen-ai-features and gen-ai-search-agent-translate features without hideAiFeatures. Any failure other than a 401 falls back to the existing path. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/api-client/client.ts | 53 ++++++ packages/mcp-core/src/api-client/schema.ts | 67 +++++++ packages/mcp-core/src/api-client/types.ts | 4 + packages/mcp-core/src/toolDefinitions.json | 2 +- .../mcp-core/src/tools/search-events.test.ts | 177 ++++++++++++++++++ .../src/tools/search-events/handler.ts | 28 ++- .../mcp-core/src/tools/search-events/seer.ts | 166 ++++++++++++++++ 7 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 packages/mcp-core/src/tools/search-events/seer.ts diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 4a64718c2..e85174059 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -42,6 +42,8 @@ import { ClientKeyListSchema, AutofixRunSchema, AutofixRunStateSchema, + SearchAgentStartSchema, + SearchAgentStateSchema, TraceMetaSchema, TraceSchema, UserSchema, @@ -61,6 +63,8 @@ import type { SentryProtocol } from "../types"; import type { AutofixRun, AutofixRunState, + SearchAgentStart, + SearchAgentState, ClientKey, ClientKeyList, Event, @@ -2614,6 +2618,55 @@ export class SentryApiService { return AutofixRunStateSchema.parse(body); } + // POST https://us.sentry.io/api/0/organizations/my-org/search-agent/start/ + async startSearchAgent( + { + organizationSlug, + projectIds, + naturalLanguageQuery, + strategy, + }: { + organizationSlug: string; + projectIds: number[]; + naturalLanguageQuery: string; + strategy: "Traces" | "Issues" | "Logs" | "Errors" | "Metrics"; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/organizations/${organizationSlug}/search-agent/start/`, + { + method: "POST", + body: JSON.stringify({ + project_ids: projectIds, + natural_language_query: naturalLanguageQuery, + strategy, + }), + }, + opts, + ); + return SearchAgentStartSchema.parse(body); + } + + // GET https://us.sentry.io/api/0/organizations/my-org/search-agent/state/f47ac10b-58cc-4372-a567-0e02b2c3d479/ + async getSearchAgentState( + { + organizationSlug, + runId, + }: { + organizationSlug: string; + runId: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/organizations/${organizationSlug}/search-agent/state/${encodeURIComponent(runId)}/`, + undefined, + opts, + ); + return SearchAgentStateSchema.parse(body); + } + /** * Retrieves high-level metadata about a trace. * diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index e7dfe321c..451bbd27a 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -93,6 +93,9 @@ export const OrganizationSchema = z organizationUrl: z.string().url(), }) .optional(), + // Only returned by the organization details endpoint, not the list endpoint. + features: z.array(z.string()).optional(), + hideAiFeatures: z.boolean().optional(), }) .passthrough(); @@ -874,6 +877,70 @@ export const AutofixRunStateSchema = z.object({ .nullable(), }); +/** + * Schemas for Seer's search agent, which translates natural language into + * Sentry search queries. + * + * Upstream source of truth in getsentry/sentry: + * - `src/sentry/seer/endpoints/search_agent_start.py` + * - `src/sentry/seer/endpoints/search_agent_state.py` + * - `src/sentry/seer/endpoints/search_agent_types.py` + */ +export const SearchAgentStartSchema = z + .object({ + // Null until Seer has picked up the run; poll with sentry_run_id instead. + run_id: z.number().nullable(), + sentry_run_id: z.string(), + }) + .passthrough(); + +export const SearchAgentQuerySchema = z + .object({ + query: z.string(), + group_by: z.array(z.string()).default([]), + visualization: z + .array( + z + .object({ + y_axes: z.array(z.string()).default([]), + }) + .passthrough(), + ) + .default([]), + sort: z.string().default(""), + // Empty when an absolute start/end range is used instead. + stats_period: z.string().default(""), + start: z.string().nullable().optional(), + end: z.string().nullable().optional(), + mode: z.string(), + // Cross-event filters, only set for the Traces strategy. + span_query: z.string().nullable().optional(), + log_query: z.string().nullable().optional(), + metric_query: z.string().nullable().optional(), + }) + .passthrough(); + +export const SearchAgentTranslateSchema = z + .object({ + responses: z.array(SearchAgentQuerySchema), + unsupported_reason: z.string().nullable().optional(), + }) + .passthrough(); + +export const SearchAgentStateSchema = z + .object({ + session: z + .object({ + // Only `status` is set while the run is still being created in Seer. + status: z.string(), + final_response: SearchAgentTranslateSchema.nullable().optional(), + unsupported_reason: z.string().nullable().optional(), + }) + .passthrough() + .nullable(), + }) + .passthrough(); + export const EventAttachmentSchema = z.object({ id: z.string(), name: z.string(), diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 23fee44f0..b5c85df50 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -43,6 +43,8 @@ import type { AssignedToSchema, AutofixRunSchema, AutofixRunStateSchema, + SearchAgentStartSchema, + SearchAgentStateSchema, ClientKeyListSchema, ClientKeySchema, DefaultEventSchema, @@ -117,6 +119,8 @@ export type EventAttachment = z.infer; export type Tag = z.infer; export type AutofixRun = z.infer; export type AutofixRunState = z.infer; +export type SearchAgentStart = z.infer; +export type SearchAgentState = z.infer; export type AssignedTo = z.infer; export type ReplayDetails = z.infer; export type ReplayList = z.infer["data"]; diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 2abb1fac5..f47fb0720 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -783,7 +783,7 @@ "dataset": { "type": "string", "enum": ["spans", "errors", "logs", "metrics", "profiles", "replays"], - "description": "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured." + "description": "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured. Pass it with projectSlug so Seer can translate natural language queries when the organization has Seer enabled." }, "query": { "type": "string", diff --git a/packages/mcp-core/src/tools/search-events.test.ts b/packages/mcp-core/src/tools/search-events.test.ts index e13fdc30a..868fd22eb 100644 --- a/packages/mcp-core/src/tools/search-events.test.ts +++ b/packages/mcp-core/src/tools/search-events.test.ts @@ -1629,4 +1629,181 @@ describe("search_events", () => { expect(mockGenerateText).not.toHaveBeenCalled(); expect(result).toContain("Database Error"); }); + + describe("with Seer", () => { + const seerParams = { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: "test-project", + dataset: "spans" as const, + query: "slowest http requests in the last day", + fields: null, + sort: null, + statsPeriod: undefined, + limit: 10, + includeExplanation: true, + }; + const context = { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }; + const seerQuery = { + query: "span.op:http.client", + group_by: ["span.description"], + visualization: [ + { chart_type: 1, y_axes: ["p95(span.duration)"], interval: null }, + ], + sort: "-p95(span.duration)", + stats_period: "24h", + start: null, + end: null, + mode: "aggregates", + result_count: 1, + span_query: null, + log_query: null, + metric_query: null, + }; + + const mockOrganization = (features: string[]) => + http.get("https://sentry.io/api/0/organizations/test-org/", () => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + features, + hideAiFeatures: false, + }), + ); + const mockProject = http.get( + "https://sentry.io/api/0/projects/test-org/test-project/", + () => HttpResponse.json({ id: "42", slug: "test-project", name: "Test" }), + ); + const mockSeerStart = vi.fn(async ({ request }: { request: Request }) => { + expect(await request.json()).toEqual({ + project_ids: [42], + natural_language_query: "slowest http requests in the last day", + strategy: "Traces", + }); + return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); + }); + const mockSeerState = (session: Record) => + http.get( + "https://sentry.io/api/0/organizations/test-org/search-agent/state/run-uuid/", + () => HttpResponse.json({ session, sentry_run_id: "run-uuid" }), + ); + + beforeEach(() => { + mockSeerStart.mockClear(); + mswServer.use( + mockProject, + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + mockSeerStart, + ), + ); + }); + + it("should translate natural language queries with Seer", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { responses: [seerQuery], 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"); + expect(url.searchParams.getAll("field")).toEqual([ + "span.description", + "p95(span.duration)", + ]); + expect(url.searchParams.get("sort")).toBe("-p95_span_duration"); + expect(url.searchParams.get("statsPeriod")).toBe("24h"); + return HttpResponse.json({ + data: [ + { + "span.description": "GET /api/users", + "p95(span.duration)": 1200, + }, + ], + }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("GET /api/users"); + expect(result).toContain("Translated by Seer's search agent."); + }); + + it("should fall back to the agent when Seer is not enabled", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-features"]), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + }); + + it("should fall back to the agent when Seer cannot translate", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ status: "error", unsupported_reason: "Unsupported" }), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + }); + + it("should fall back to the agent when Seer returns 403", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + () => + HttpResponse.json( + { detail: "Feature flag not enabled" }, + { status: 403 }, + ), + ), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler(seerParams, context); + + expect(mockGenerateText).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/mcp-core/src/tools/search-events/handler.ts b/packages/mcp-core/src/tools/search-events/handler.ts index 055b06acf..29b1e212f 100644 --- a/packages/mcp-core/src/tools/search-events/handler.ts +++ b/packages/mcp-core/src/tools/search-events/handler.ts @@ -29,6 +29,7 @@ import { type PublicEventsDataset, } from "../../utils/events-datasets"; import { isAggregateQuery } from "./utils"; +import { isSeerSearchDataset, translateWithSeer } from "./seer"; import { DEFAULT_REPLAY_SORT, DEFAULT_REPLAY_STATS_PERIOD, @@ -116,7 +117,7 @@ export default defineTool({ .enum(SEARCH_EVENTS_DATASETS) .optional() .describe( - "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured.", + "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured. Pass it with projectSlug so Seer can translate natural language queries when the organization has Seer enabled.", ), query: z .string() @@ -209,7 +210,30 @@ export default defineTool({ let explanation: string | undefined; let environment: string | string[] | null | undefined = params.environment; - if (hasAgentProvider()) { + // Seer only translates into the dataset it is given and needs a project to + // search in, so it runs only when both are explicit. + const seerTranslation = + params.query && + projectId && + isSeerSearchDataset(params.dataset) && + !params.environment + ? await translateWithSeer({ + apiService, + organizationSlug, + projectId, + dataset: params.dataset, + query: params.query, + }) + : null; + + if (seerTranslation) { + dataset = inputDataset; + sentryQuery = seerTranslation.query; + fields = seerTranslation.fields; + sortParam = seerTranslation.sort; + timeParams = seerTranslation.timeParams; + explanation = seerTranslation.explanation; + } else if (hasAgentProvider()) { const agentResult = await searchEventsAgent({ query: buildSearchRepairPrompt({ query: params.query, diff --git a/packages/mcp-core/src/tools/search-events/seer.ts b/packages/mcp-core/src/tools/search-events/seer.ts new file mode 100644 index 000000000..9c12fc541 --- /dev/null +++ b/packages/mcp-core/src/tools/search-events/seer.ts @@ -0,0 +1,166 @@ +import type { z } from "zod"; +import type { SentryApiService } from "../../api-client"; +import { + ApiAuthenticationError, + type SearchAgentQuerySchema, +} from "../../api-client/index"; +import { logWarn } from "../../telem/logging"; +import { + normalizeEventsDataset, + type PublicEventsDataset, +} from "../../utils/events-datasets"; +import { RECOMMENDED_FIELDS } from "./config"; + +export const SEER_SEARCH_AGENT_POLLING_INTERVAL = 1000; // 1 second +export const SEER_SEARCH_AGENT_TIMEOUT = 60 * 1000; // 1 minute + +// Organization features the search agent endpoints require. `hideAiFeatures` +// is checked separately. +const REQUIRED_FEATURES = ["gen-ai-features", "gen-ai-search-agent-translate"]; + +const SEER_STRATEGIES = { + errors: "Errors", + logs: "Logs", + spans: "Traces", + metrics: "Metrics", +} as const satisfies Partial>; + +export type SeerSearchDataset = keyof typeof SEER_STRATEGIES; + +export function isSeerSearchDataset( + dataset: string | undefined, +): dataset is SeerSearchDataset { + return dataset !== undefined && dataset in SEER_STRATEGIES; +} + +export interface SeerSearchTranslation { + query: string; + fields: string[]; + sort: string; + timeParams: { statsPeriod?: string; start?: string; end?: string }; + explanation: string; +} + +async function hasSeerSearchAgentAccess( + apiService: SentryApiService, + organizationSlug: string, +): Promise { + const organization = await apiService.getOrganization(organizationSlug); + if (organization.hideAiFeatures) { + return false; + } + const features = organization.features ?? []; + return REQUIRED_FEATURES.every((feature) => features.includes(feature)); +} + +function toSearchTranslation( + result: z.output, + dataset: SeerSearchDataset, +): SeerSearchTranslation { + const aggregates = result.visualization.flatMap((chart) => chart.y_axes); + const fields = + result.mode === "aggregates" + ? [...new Set([...result.group_by, ...aggregates])] + : []; + if (fields.length === 0) { + fields.push(...RECOMMENDED_FIELDS[normalizeEventsDataset(dataset)].basic); + } + + const sort = + result.sort.trim() || + (result.mode === "aggregates" && aggregates[0] + ? `-${aggregates[0]}` + : "-timestamp"); + // Sentry requires the sort field to be selected. + const sortField = sort.startsWith("-") ? sort.slice(1) : sort; + if (!fields.includes(sortField)) { + fields.push(sortField); + } + + let timeParams: SeerSearchTranslation["timeParams"]; + if (result.stats_period) { + timeParams = { statsPeriod: result.stats_period }; + } else if (result.start && result.end) { + timeParams = { start: result.start, end: result.end }; + } else { + timeParams = { statsPeriod: "14d" }; + } + + let explanation = "Translated by Seer's search agent."; + const crossEventQueries = [ + result.span_query, + result.log_query, + result.metric_query, + ].filter(Boolean); + if (crossEventQueries.length > 0) { + explanation += ` Seer also suggested cross-event filters (${crossEventQueries.join(", ")}), which search_events does not apply.`; + } + + return { query: result.query, fields, sort, timeParams, explanation }; +} + +/** + * Translates a natural language query with Seer's search agent. + * + * Returns null when Seer is unavailable for the organization, cannot translate + * the query, or does not finish in time, so the caller can fall back to the + * embedded agent or direct query syntax. + */ +export async function translateWithSeer({ + apiService, + organizationSlug, + projectId, + dataset, + query, +}: { + apiService: SentryApiService; + organizationSlug: string; + projectId: string; + dataset: SeerSearchDataset; + query: string; +}): Promise { + try { + if (!(await hasSeerSearchAgentAccess(apiService, organizationSlug))) { + return null; + } + + const run = await apiService.startSearchAgent({ + organizationSlug, + projectIds: [Number(projectId)], + naturalLanguageQuery: query, + strategy: SEER_STRATEGIES[dataset], + }); + + const deadline = Date.now() + SEER_SEARCH_AGENT_TIMEOUT; + while (Date.now() < deadline) { + const { session } = await apiService.getSearchAgentState({ + organizationSlug, + runId: run.sentry_run_id, + }); + + if (session?.status === "completed") { + const result = session.final_response?.responses[0]; + return result ? toSearchTranslation(result, dataset) : null; + } + if (session?.status === "error") { + return null; + } + + await new Promise((resolve) => + setTimeout(resolve, SEER_SEARCH_AGENT_POLLING_INTERVAL), + ); + } + + logWarn("Seer search agent timed out", { + extra: { organizationSlug, runId: run.sentry_run_id }, + }); + return null; + } catch (error) { + // An invalid token should surface to the user rather than fall back. + if (error instanceof ApiAuthenticationError) { + throw error; + } + logWarn(error, { extra: { organizationSlug } }); + return null; + } +} From 9b033944a3715ad16d6c980a3f87b88f602d7dc0 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 13:20:04 -0400 Subject: [PATCH 02/19] chore(api-client): Remove fake example UUID from search-agent comment The endpoint comment used a made-up run ID (the RFC 4122 sample UUID), which could be mistaken for a real one. Use a {runId} placeholder. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/mcp-core/src/api-client/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index e85174059..53bffa14f 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -2648,7 +2648,7 @@ export class SentryApiService { return SearchAgentStartSchema.parse(body); } - // GET https://us.sentry.io/api/0/organizations/my-org/search-agent/state/f47ac10b-58cc-4372-a567-0e02b2c3d479/ + // GET https://us.sentry.io/api/0/organizations/my-org/search-agent/state/{runId}/ async getSearchAgentState( { organizationSlug, From 69054056c20d7ae39a8c0b53e18a945c2cd6c177 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 13:20:06 -0400 Subject: [PATCH 03/19] fix(search-events): Request feature flags when checking Seer access Sentry omits `features` from the organization response unless include_feature_flags=1 is passed, so the Seer search agent check never saw the flag. Request it explicitly (with detailed=0 to skip projects/teams) and make the test mock mirror Sentry's behavior. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/mcp-core/src/api-client/client.ts | 19 +++++++++++++-- .../mcp-core/src/tools/search-events.test.ts | 23 ++++++++++++------- .../mcp-core/src/tools/search-events/seer.ts | 6 ++++- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 53bffa14f..0b6e1bb99 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -1092,12 +1092,27 @@ export class SentryApiService { * Gets a single organization by slug. * * @param organizationSlug Organization identifier + * @param params Query parameters + * @param params.includeFeatureFlags Include `features` in the response (omitted by Sentry otherwise) + * @param params.detailed Include projects and teams (Sentry defaults to true) * @param opts Request options including host override * @returns Organization data */ - async getOrganization(organizationSlug: string, opts?: RequestOptions) { + async getOrganization( + organizationSlug: string, + params?: { includeFeatureFlags?: boolean; detailed?: boolean }, + opts?: RequestOptions, + ) { + const queryParams = new URLSearchParams(); + if (params?.includeFeatureFlags) { + queryParams.set("include_feature_flags", "1"); + } + if (params?.detailed === false) { + queryParams.set("detailed", "0"); + } + const queryString = queryParams.toString(); const body = await this.requestJSON( - `/organizations/${organizationSlug}/`, + `/organizations/${organizationSlug}/${queryString ? `?${queryString}` : ""}`, undefined, opts, ); diff --git a/packages/mcp-core/src/tools/search-events.test.ts b/packages/mcp-core/src/tools/search-events.test.ts index 868fd22eb..2ee27b60a 100644 --- a/packages/mcp-core/src/tools/search-events.test.ts +++ b/packages/mcp-core/src/tools/search-events.test.ts @@ -1670,14 +1670,21 @@ describe("search_events", () => { }; const mockOrganization = (features: string[]) => - http.get("https://sentry.io/api/0/organizations/test-org/", () => - HttpResponse.json({ - id: "1", - slug: "test-org", - name: "Test Org", - features, - hideAiFeatures: false, - }), + http.get( + "https://sentry.io/api/0/organizations/test-org/", + ({ request }) => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + // Sentry only serializes features when explicitly requested. + ...(new URL(request.url).searchParams.get( + "include_feature_flags", + ) === "1" + ? { features } + : {}), + hideAiFeatures: false, + }), ); const mockProject = http.get( "https://sentry.io/api/0/projects/test-org/test-project/", diff --git a/packages/mcp-core/src/tools/search-events/seer.ts b/packages/mcp-core/src/tools/search-events/seer.ts index 9c12fc541..34bbc09c4 100644 --- a/packages/mcp-core/src/tools/search-events/seer.ts +++ b/packages/mcp-core/src/tools/search-events/seer.ts @@ -45,7 +45,11 @@ async function hasSeerSearchAgentAccess( apiService: SentryApiService, organizationSlug: string, ): Promise { - const organization = await apiService.getOrganization(organizationSlug); + // Sentry omits `features` unless explicitly requested. + const organization = await apiService.getOrganization(organizationSlug, { + includeFeatureFlags: true, + detailed: false, + }); if (organization.hideAiFeatures) { return false; } From bcf17e613dcac5113ab170dab8174389fbc479cb Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 13:40:15 -0400 Subject: [PATCH 04/19] fix(search-events): Respect explicit period and grouping with Seer An explicit `period` now wins over Seer's time range, since Seer never sees it. Stop appending Seer's sort to the fields, which the handler already does safely, and fall back to the default aggregate sort when Seer picks a non-aggregate sort that would change the grouping. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tools/catalog/search-events.test.ts | 51 +++++++++++++++++++ .../src/tools/catalog/search-events.ts | 5 +- .../src/tools/support/search-events/seer.ts | 20 +++++--- 3 files changed, 68 insertions(+), 8 deletions(-) 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 516efc034..428c64dc5 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3456,6 +3456,57 @@ describe("search_events", () => { expect(result).toContain("Translated by Seer's search agent."); }); + it("should prefer an explicit period over Seer's time range", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { responses: [seerQuery], 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("statsPeriod")).toBe("7d"); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchEvents.handler({ ...seerParams, period: "7d" }, context); + + expect(mockSeerStart).toHaveBeenCalled(); + }); + + it("should not group by a non-aggregate Seer sort", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [{ ...seerQuery, sort: "-timestamp" }], + unsupported_reason: null, + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll("field")).toEqual([ + "span.description", + "p95(span.duration)", + ]); + expect(url.searchParams.get("sort")).toBe("-p95(span.duration)"); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + }); + it("should fall back to the agent when Seer is not enabled", async () => { mockGenerateText.mockResolvedValueOnce( mockAIResponse("spans", "span.op:http.client"), diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 653ead45a..0a689bc81 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -601,7 +601,10 @@ export default defineTool({ sentryQuery = seerTranslation.query; fields = seerTranslation.fields; sortParam = seerTranslation.sort; - timeParams = seerTranslation.timeParams; + // Seer never sees `period`, so an explicit one wins over its time range. + timeParams = hasExplicitPeriod + ? { statsPeriod: params.period } + : seerTranslation.timeParams; explanation = seerTranslation.explanation; } else if (willRunAgent) { const parsed = await withProviderFallback({ diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index 943410a79..aa01bebfb 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -70,15 +70,21 @@ function toSearchTranslation( fields.push(...RECOMMENDED_FIELDS[normalizeEventsDataset(dataset)].basic); } - const sort = - result.sort.trim() || - (result.mode === "aggregates" && aggregates[0] + const defaultSort = + result.mode === "aggregates" && aggregates[0] ? `-${aggregates[0]}` - : "-timestamp"); - // Sentry requires the sort field to be selected. + : "-timestamp"; + let sort = result.sort.trim() || defaultSort; + // The handler adds the sort field to the selected fields, except for a + // non-aggregate sort in an aggregate query since that would change the + // grouping. Fall back to the default sort instead of letting Sentry reject it. const sortField = sort.startsWith("-") ? sort.slice(1) : sort; - if (!fields.includes(sortField)) { - fields.push(sortField); + if ( + result.mode === "aggregates" && + !sortField.includes("(") && + !fields.includes(sortField) + ) { + sort = defaultSort; } let timeParams: SeerSearchTranslation["timeParams"]; From de68753cba0e38d841647103e5ece7b411446532 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 13:40:26 -0400 Subject: [PATCH 05/19] chore: Revert unrelated reformatting from the main merge The pre-commit hook reformatted files pulled in from main when the merge was committed. Restore them to main's version so the PR only contains this branch's changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/homepage-content.test.ts | 4 +- packages/mcp-cloudflare/src/server/metrics.ts | 3 +- .../src/server/oauth/telemetry.ts | 6 +- .../src/internal/agents/tools/data/app.json | 165 ++++++++++++++---- .../src/internal/error-handling.test.ts | 29 ++- .../tools/support/search-events/utils.test.ts | 10 +- .../src/tools/support/search-events/utils.ts | 13 +- packages/mcp-server-mocks/src/utils.ts | 4 +- 8 files changed, 166 insertions(+), 68 deletions(-) diff --git a/packages/mcp-cloudflare/src/homepage-content.test.ts b/packages/mcp-cloudflare/src/homepage-content.test.ts index 39b154dad..17e9c0bbb 100644 --- a/packages/mcp-cloudflare/src/homepage-content.test.ts +++ b/packages/mcp-cloudflare/src/homepage-content.test.ts @@ -38,9 +38,7 @@ describe("homepage-content", () => { }); it("escapes HTML in generated content", () => { - const html = generateHomepageFallbackHtml( - 'https://example.com/"onload="', - ); + const html = generateHomepageFallbackHtml('https://example.com/"onload="'); expect(html).toContain("""); expect(html).not.toContain('onload="'); }); diff --git a/packages/mcp-cloudflare/src/server/metrics.ts b/packages/mcp-cloudflare/src/server/metrics.ts index 96e584737..7480a68c6 100644 --- a/packages/mcp-cloudflare/src/server/metrics.ts +++ b/packages/mcp-cloudflare/src/server/metrics.ts @@ -312,7 +312,8 @@ export function recordResponseMetric( } if (options?.oauthErrorReason) { - responseAttributes[OAUTH_ERROR_REASON_ATTRIBUTE] = options.oauthErrorReason; + responseAttributes[OAUTH_ERROR_REASON_ATTRIBUTE] = + options.oauthErrorReason; } if (options?.oauthBearerShape) { diff --git a/packages/mcp-cloudflare/src/server/oauth/telemetry.ts b/packages/mcp-cloudflare/src/server/oauth/telemetry.ts index a2bd7114a..9b26a896c 100644 --- a/packages/mcp-cloudflare/src/server/oauth/telemetry.ts +++ b/packages/mcp-cloudflare/src/server/oauth/telemetry.ts @@ -39,14 +39,14 @@ export type OAuthErrorTelemetry = { */ export const ACCESS_METHOD_ATTRIBUTE = "app.access.method" as const; export const OAUTH_ERROR_ATTRIBUTE = "app.access.error.code" as const; -export const OAUTH_ERROR_REASON_ATTRIBUTE = "app.access.error.reason" as const; +export const OAUTH_ERROR_REASON_ATTRIBUTE = + "app.access.error.reason" as const; export const OAUTH_REQUEST_HEADER_SHAPE_ATTRIBUTE = "app.access.request.header_shape" as const; export const OAUTH_REFRESH_OUTCOME_ATTRIBUTE = "app.access.refresh.outcome" as const; export const OAUTH_GRANT_SHAPE_ATTRIBUTE = "app.access.grant.shape" as const; -export const OAUTH_GRANT_ID_HASH_ATTRIBUTE = - "app.access.grant.id_hash" as const; +export const OAUTH_GRANT_ID_HASH_ATTRIBUTE = "app.access.grant.id_hash" as const; export const OAUTH_GRANT_AGE_BUCKET_ATTRIBUTE = "app.access.grant.age_bucket" as const; export const OAUTH_GRANT_REVOKED_REASON_ATTRIBUTE = diff --git a/packages/mcp-core/src/internal/agents/tools/data/app.json b/packages/mcp-core/src/internal/agents/tools/data/app.json index d173e692e..7e31b23ef 100644 --- a/packages/mcp-core/src/internal/agents/tools/data/app.json +++ b/packages/mcp-core/src/internal/agents/tools/data/app.json @@ -7,7 +7,12 @@ "description": "Scrubber-safe OAuth error code returned by Sentry MCP or the OAuth provider.", "type": "string", "note": "Emitted under app.access.* so default Sentry data scrubbing does not redact values via sensitive substrings (auth/oauth, token, bearer, credentials). invalid_token responses are bucketed as invalid_access.", - "examples": ["invalid_access", "invalid_grant", "invalid_client", "other"] + "examples": [ + "invalid_access", + "invalid_grant", + "invalid_client", + "other" + ] }, "app.access.error.reason": { "description": "Low-cardinality scrubber-safe bucket for an OAuth error description.", @@ -23,12 +28,19 @@ "app.access.grant.age_bucket": { "description": "Bounded MCP grant age at refresh or revocation.", "type": "string", - "examples": ["lt_1h", "1d_7d", "gte_30d", "unknown"] + "examples": [ + "lt_1h", + "1d_7d", + "gte_30d", + "unknown" + ] }, "app.access.grant.id_hash": { "description": "Deterministic non-secret fingerprint of a Sentry MCP OAuth grant ID used to correlate grant lifecycle logs.", "type": "string", - "examples": ["a1b2c3d4"] + "examples": [ + "a1b2c3d4" + ] }, "app.access.grant.revoked_reason": { "description": "Reason a Sentry MCP OAuth wrapper grant was revoked.", @@ -42,22 +54,36 @@ "app.access.grant.shape": { "description": "Shape of the Sentry MCP OAuth grant used during refresh.", "type": "string", - "examples": ["refreshable"] + "examples": [ + "refreshable" + ] }, "app.access.method": { "description": "How the MCP request obtained upstream Sentry access.", "type": "string", - "examples": ["mcp_grant", "sentry_access"] + "examples": [ + "mcp_grant", + "sentry_access" + ] }, "app.access.probe.reason": { "description": "Sentry MCP bucket for an indeterminate upstream OAuth access probe.", "type": "string", - "examples": ["rate_limit", "server_error", "unknown"] + "examples": [ + "rate_limit", + "server_error", + "unknown" + ] }, "app.access.probe.status_code": { "description": "HTTP status returned by the upstream Sentry access-validity probe.", "type": "string", - "examples": ["200", "401", "429", "500"] + "examples": [ + "200", + "401", + "429", + "500" + ] }, "app.access.refresh.outcome": { "description": "Outcome bucket for a Sentry MCP OAuth refresh/token-exchange path.", @@ -84,135 +110,212 @@ "app.access.upstream.expires_in_bucket": { "description": "Bounded remaining time before the original upstream Sentry expiry at refresh or revocation.", "type": "string", - "examples": ["expired", "lt_1h", "1d_7d", "unknown"] + "examples": [ + "expired", + "lt_1h", + "1d_7d", + "unknown" + ] }, "app.client.family": { "description": "Sentry MCP low-cardinality bucket for the MCP client family.", "type": "string", "note": "Resolved from the MCP client's User-Agent on direct MCP requests, or from the registered client name on browser-mediated OAuth endpoints.", - "examples": ["claude-code", "cursor", "codex", "unknown"] + "examples": [ + "claude-code", + "cursor", + "codex", + "unknown" + ] }, "app.consent.skill": { "description": "Sentry MCP skill granted during OAuth approval.", "type": "string", - "examples": ["inspect", "docs", "project-management"] + "examples": [ + "inspect", + "docs", + "project-management" + ] }, "app.consent.skill..granted": { "description": "Whether a normalized Sentry MCP skill was granted for a specific MCP request.", "type": "boolean", "note": "The dynamic segment uses the skill id with hyphens replaced by underscores.", - "examples": ["app.consent.skill.project_management.granted"] + "examples": [ + "app.consent.skill.project_management.granted" + ] }, "app.constraint.organization_slug": { "description": "Sentry MCP organization slug constraint applied to the MCP session.", "type": "string", - "examples": ["my-org"] + "examples": [ + "my-org" + ] }, "app.constraint.project_slug": { "description": "Sentry MCP project slug constraint applied to the MCP session.", "type": "string", - "examples": ["backend"] + "examples": [ + "backend" + ] }, "app.installation.id": { "description": "A unique identifier representing the installation of an application on a specific device\n", "type": "string", "note": "Its value SHOULD persist across launches of the same application installation, including through application upgrades.\nIt SHOULD change if the application is uninstalled or if all applications of the vendor are uninstalled.\nAdditionally, users might be able to reset this value (e.g. by clearing application data).\nIf an app is installed multiple times on the same device (e.g. in different accounts on Android), each `app.installation.id` SHOULD have a different value.\nIf multiple OpenTelemetry SDKs are used within the same application, they SHOULD use the same value for `app.installation.id`.\nHardware IDs (e.g. serial number, IMEI, MAC address) MUST NOT be used as the `app.installation.id`.\n\nFor iOS, this value SHOULD be equal to the [vendor identifier](https://developer.apple.com/documentation/uikit/uidevice/identifierforvendor).\n\nFor Android, examples of `app.installation.id` implementations include:\n\n- [Firebase Installation ID](https://firebase.google.com/docs/projects/manage-installations).\n- A globally unique UUID which is persisted across sessions in your application.\n- [App set ID](https://developer.android.com/identity/app-set-id).\n- [`Settings.getString(Settings.Secure.ANDROID_ID)`](https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID).\n\nMore information about Android identifier best practices can be found [here](https://developer.android.com/training/articles/user-data-ids).\n", "stability": "development", - "examples": ["2ab2916d-a51f-4ac8-80ee-45ac31a28092"] + "examples": [ + "2ab2916d-a51f-4ac8-80ee-45ac31a28092" + ] }, "app.rate_limit.scope": { "description": "Scope of a Sentry MCP local rate-limit response.", "type": "string", - "examples": ["ip", "user", "sentry_access"], + "examples": [ + "ip", + "user", + "sentry_access" + ], "note": "sentry_access is the scrubber-safe bucket for direct Sentry access-header rate limits." }, "app.referrer.family": { "description": "Sentry MCP low-cardinality bucket for the external referrer host.", "type": "string", - "examples": ["sentry-docs", "github", "unknown"] + "examples": [ + "sentry-docs", + "github", + "unknown" + ] }, "app.request.duration_ms": { "description": "Sentry MCP request duration in milliseconds.", "type": "number", - "examples": ["42", "1250"] + "examples": [ + "42", + "1250" + ] }, "app.resource.type": { "description": "Sentry MCP resource type resolved by get_sentry_resource.", "type": "string", - "examples": ["issue", "event", "trace", "replay"] + "examples": [ + "issue", + "event", + "trace", + "replay" + ] }, "app.response.reason": { "description": "Sentry MCP local response reason bucket.", "type": "string", - "examples": ["local_rate_limit"] + "examples": [ + "local_rate_limit" + ] }, "app.response.status_class": { "description": "HTTP response status family for Sentry MCP response metrics.", "type": "string", - "examples": ["2xx", "4xx", "5xx"] + "examples": [ + "2xx", + "4xx", + "5xx" + ] }, "app.route.group": { "description": "Sentry MCP coarse route family.", "type": "string", - "examples": ["mcp", "oauth", "chat", "search"] + "examples": [ + "mcp", + "oauth", + "chat", + "search" + ] }, "app.screen.coordinate.x": { "description": "The x (horizontal) coordinate of a screen coordinate, in screen pixels.", "type": "number", "stability": "development", - "examples": ["0", "131"] + "examples": [ + "0", + "131" + ] }, "app.screen.coordinate.y": { "description": "The y (vertical) component of a screen coordinate, in screen pixels.\n", "type": "number", "stability": "development", - "examples": ["12", "99"] + "examples": [ + "12", + "99" + ] }, "app.server.mode.experimental": { "description": "Whether Sentry MCP experimental mode is enabled for the request or stdio session.", "type": "boolean", "note": "Metrics and tags record this as the low-cardinality string values `true` or `false`; spans record it as a boolean.", - "examples": [true, false] + "examples": [ + true, + false + ] }, "app.server.version": { "description": "Sentry MCP server package version.", "type": "string", - "examples": ["0.35.0"] + "examples": [ + "0.35.0" + ] }, "app.transport": { "description": "Sentry MCP product transport label.", "type": "string", - "examples": ["http", "stdio"] + "examples": [ + "http", + "stdio" + ] }, "app.upstream.host": { "description": "Configured upstream Sentry host for Sentry MCP.", "type": "string", - "examples": ["sentry.io"] + "examples": [ + "sentry.io" + ] }, "app.url.full": { "description": "Configured Sentry MCP URL for stdio telemetry.", "type": "string", "note": "Do not include credentials or access tokens in this URL.", - "examples": ["https://mcp.sentry.dev/mcp"] + "examples": [ + "https://mcp.sentry.dev/mcp" + ] }, "app.utm_source": { "description": "Sanitized in-product utm_source query parameter for Sentry MCP attribution.", "type": "string", - "examples": ["docs", "product"] + "examples": [ + "docs", + "product" + ] }, "app.widget.id": { "description": "An identifier that uniquely differentiates this widget from other widgets in the same application.\n", "type": "string", "note": "A widget is an application component, typically an on-screen visual GUI element.\n", "stability": "development", - "examples": ["f9bc787d-ff05-48ad-90e1-fca1d46130b3", "submit_order_1829"] + "examples": [ + "f9bc787d-ff05-48ad-90e1-fca1d46130b3", + "submit_order_1829" + ] }, "app.widget.name": { "description": "The name of an application widget.", "type": "string", "note": "A widget is an application component, typically an on-screen visual GUI element.\n", "stability": "development", - "examples": ["submit", "attack", "Clear Cart"] + "examples": [ + "submit", + "attack", + "Clear Cart" + ] } } } diff --git a/packages/mcp-core/src/internal/error-handling.test.ts b/packages/mcp-core/src/internal/error-handling.test.ts index 6648af8eb..1d9505180 100644 --- a/packages/mcp-core/src/internal/error-handling.test.ts +++ b/packages/mcp-core/src/internal/error-handling.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { formatErrorForUser, isExpectedToolError } from "./error-handling"; +import { + formatErrorForUser, + isExpectedToolError, +} from "./error-handling"; import { AgentExecutionError, UserInputError, @@ -82,9 +85,7 @@ describe("formatErrorForUser", () => { it("returns detailed message for stdio transport", async () => { const result = await formatErrorForUser(error, { transport: "stdio" }); - expect(result).toContain( - "Workspace monthly budget of $15000.00 exceeded", - ); + expect(result).toContain("Workspace monthly budget of $15000.00 exceeded"); expect(result).toContain("**AI Provider Error**"); expect(result).toContain("Other non-AI tools should still work"); expect(result).not.toContain("Feature Unavailable"); @@ -100,9 +101,7 @@ describe("formatErrorForUser", () => { it("returns graceful availability message for http transport without creating an issue", async () => { const result = await formatErrorForUser(error, { transport: "http" }); expect(result).toContain("**Feature Unavailable**"); - expect(result).toContain( - "AI-powered features are temporarily unavailable", - ); + expect(result).toContain("AI-powered features are temporarily unavailable"); expect(result).toContain("do not require AI should still work"); expect(result).not.toContain( "Workspace monthly budget of $15000.00 exceeded", @@ -119,9 +118,7 @@ describe("formatErrorForUser", () => { it("returns detailed message when transport is undefined", async () => { const result = await formatErrorForUser(error); - expect(result).toContain( - "Workspace monthly budget of $15000.00 exceeded", - ); + expect(result).toContain("Workspace monthly budget of $15000.00 exceeded"); expect(logIssue).not.toHaveBeenCalled(); expect(logWarn).toHaveBeenCalled(); }); @@ -148,9 +145,7 @@ describe("formatErrorForUser", () => { it("returns graceful availability message for http transport without creating an issue", async () => { const result = await formatErrorForUser(error, { transport: "http" }); expect(result).toContain("**Feature Unavailable**"); - expect(result).toContain( - "AI-powered features are temporarily unavailable", - ); + expect(result).toContain("AI-powered features are temporarily unavailable"); expect(result).not.toContain("Invalid API key provided"); expect(logIssue).not.toHaveBeenCalled(); expect(logWarn).toHaveBeenCalled(); @@ -175,9 +170,7 @@ describe("formatErrorForUser", () => { it("returns graceful availability message without creating an issue", async () => { const result = await formatErrorForUser(error, { transport: "http" }); - expect(result).toContain( - "AI-powered features are temporarily unavailable", - ); + expect(result).toContain("AI-powered features are temporarily unavailable"); expect(result).not.toContain("Internal server error"); expect(logIssue).not.toHaveBeenCalled(); expect(logWarn).toHaveBeenCalled(); @@ -213,9 +206,7 @@ describe("formatErrorForUser", () => { it("returns graceful availability message without creating an issue", async () => { const result = await formatErrorForUser(error, { transport: "http" }); - expect(result).toContain( - "AI-powered features are temporarily unavailable", - ); + expect(result).toContain("AI-powered features are temporarily unavailable"); expect(logIssue).not.toHaveBeenCalled(); expect(logWarn).toHaveBeenCalled(); }); diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index f2a15d550..d2bca4fa1 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -313,10 +313,12 @@ describe("search query helpers", () => { // Bare substring matches are not enough — short values must not false-hit // inside unrelated full-text (e.g. "1" inside "401"). - expect(isSemanticFilterDowngrade("id:1", 'message:"error 401"')).toBe( - false, - ); - expect(isSemanticFilterDowngrade("id:1", 'message:"error 1"')).toBe(true); + expect( + isSemanticFilterDowngrade("id:1", 'message:"error 401"'), + ).toBe(false); + expect( + isSemanticFilterDowngrade("id:1", 'message:"error 1"'), + ).toBe(true); }); }); diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index c9279604a..79028959a 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -177,10 +177,7 @@ function normalizeFilterValue(rawValue: string): string { * Read one filter value starting at `valueStart` in the original query. * Quoted values keep interior whitespace; unquoted values stop at whitespace. */ -function readRawFilterValue( - query: string, - valueStart: number, -): string | undefined { +function readRawFilterValue(query: string, valueStart: number): string | undefined { if (valueStart >= query.length) { return undefined; } @@ -244,7 +241,9 @@ function searchFilterOccurrences(query: string): SearchFilterOccurrence[] { return occurrences; } -function structuredFilterOccurrences(query: string): SearchFilterOccurrence[] { +function structuredFilterOccurrences( + query: string, +): SearchFilterOccurrence[] { return searchFilterOccurrences(query).filter( (occurrence) => !FULL_TEXT_SEARCH_KEYS.has(occurrence.key), ); @@ -285,7 +284,9 @@ function containsAsWholeToken(haystack: string, needle: string): boolean { } function isRelatedFilterValue(left: string, right: string): boolean { - return containsAsWholeToken(left, right) || containsAsWholeToken(right, left); + return ( + containsAsWholeToken(left, right) || containsAsWholeToken(right, left) + ); } /** diff --git a/packages/mcp-server-mocks/src/utils.ts b/packages/mcp-server-mocks/src/utils.ts index 74fc4674f..0a397e634 100644 --- a/packages/mcp-server-mocks/src/utils.ts +++ b/packages/mcp-server-mocks/src/utils.ts @@ -10,7 +10,9 @@ export function setupMockServer(handlers: Array = []): SetupServer { * Start the MSW server with common configuration for Sentry MCP tests * This helper ensures consistent configuration across all test suites */ -export function startMockServer(options?: { ignoreOpenAI?: boolean }): void { +export function startMockServer(options?: { + ignoreOpenAI?: boolean; +}): void { const { ignoreOpenAI = true } = options || {}; mswServer.listen({ From d66e3797a69d4f675a1a30cd19ba048eb716d52c Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 14:50:04 -0400 Subject: [PATCH 06/19] fix(search-events): Skip Seer for structured queries and explicit fields Seer only sees the natural language query, so it rewrote structured Sentry syntax and dropped caller-provided fields or sort. Send those requests to the embedded agent, which preserves them. Co-Authored-By: Claude Opus 5.5 --- .../src/tools/catalog/search-events.test.ts | 21 +++++++++++++++++++ .../src/tools/catalog/search-events.ts | 8 +++++-- 2 files changed, 27 insertions(+), 2 deletions(-) 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 428c64dc5..307b74df6 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3507,6 +3507,27 @@ describe("search_events", () => { expect(mockSeerStart).toHaveBeenCalled(); }); + it.each([ + ["a structured query", { query: "span.op:http.client" }], + ["explicit fields", { fields: ["span.description", "count()"] }], + ["an explicit sort", { sort: "-count()" }], + ])("should skip Seer for %s", async (_, overrides) => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler({ ...seerParams, ...overrides }, context); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + }); + it("should fall back to the agent when Seer is not enabled", async () => { mockGenerateText.mockResolvedValueOnce( mockAIResponse("spans", "span.op:http.client"), diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 0a689bc81..9da7a3bbe 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -563,13 +563,17 @@ export default defineTool({ // 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 and needs a project to - // search in, so it runs only when both are explicit. + // search in, so it runs only when both are explicit. It only sees the + // natural language query, so skip it for structured queries and explicit + // fields or sort, which the embedded agent preserves. const seerTranslation = params.query && projectId && isSeerSearchDataset(params.dataset) && !params.environment && - !canRunWithoutAgent + !hasStructuredQuery && + !hasExplicitFields && + !hasExplicitSort ? await translateWithSeer({ apiService, organizationSlug, From 2cc0116abe96e5fbcf2b1506ddd84bb21780b351 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 14:54:45 -0400 Subject: [PATCH 07/19] feat(search-events): Use Seer without a projectSlug The search agent needs non-empty project IDs, but Sentry treats -1 as all projects the user can access. Send that when no projectSlug is given so org-wide natural language searches can use Seer too. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/toolDefinitions.json | 2 +- .../src/tools/catalog/search-events.test.ts | 28 +++++++++++++++++++ .../src/tools/catalog/search-events.ts | 11 ++++---- .../src/tools/support/search-events/seer.ts | 7 +++-- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index c3a97bef4..a96bea830 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7034,7 +7034,7 @@ "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." }, "dataset": { - "description": "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured. Pass it with projectSlug so Seer can translate natural language queries when the organization has Seer enabled.", + "description": "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured. Pass it so Seer can translate natural language queries when the organization has Seer enabled.", "type": "string", "enum": ["spans", "errors", "logs", "metrics", "profiles", "replays"] }, 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 307b74df6..7605230af 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3456,6 +3456,34 @@ describe("search_events", () => { expect(result).toContain("Translated by Seer's search agent."); }); + it("should search all accessible projects without a projectSlug", async () => { + const mockAllProjectsStart = vi.fn( + async ({ request }: { request: Request }) => { + expect(await request.json()).toMatchObject({ project_ids: [-1] }); + return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); + }, + ); + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + mockAllProjectsStart, + ), + mockSeerState({ + status: "completed", + final_response: { responses: [seerQuery], unsupported_reason: null }, + }), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler({ ...seerParams, projectSlug: null }, context); + + expect(mockAllProjectsStart).toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + it("should prefer an explicit period over Seer's time range", async () => { mswServer.use( mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 9da7a3bbe..2b1419ab9 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -421,7 +421,7 @@ export default defineTool({ .enum(SEARCH_EVENTS_DATASETS) .optional() .describe( - "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured. Pass it with projectSlug so Seer can translate natural language queries when the organization has Seer enabled.", + "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured. Pass it so Seer can translate natural language queries when the organization has Seer enabled.", ), query: z .string() @@ -562,13 +562,12 @@ export default defineTool({ // (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 and needs a project to - // search in, so it runs only when both are explicit. It only sees the - // natural language query, so skip it for structured queries and explicit - // fields or sort, which the embedded agent preserves. + // 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. const seerTranslation = params.query && - projectId && isSeerSearchDataset(params.dataset) && !params.environment && !hasStructuredQuery && diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index aa01bebfb..4df066a7a 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -14,6 +14,9 @@ import { RECOMMENDED_FIELDS } from "./config"; export const SEER_SEARCH_AGENT_POLLING_INTERVAL = 1000; // 1 second export const SEER_SEARCH_AGENT_TIMEOUT = 60 * 1000; // 1 minute +// Sentry's sentinel for all projects the user can access. +const ALL_ACCESSIBLE_PROJECTS = -1; + // Organization features the search agent endpoints require. `hideAiFeatures` // is checked separately. const REQUIRED_FEATURES = ["gen-ai-features", "gen-ai-search-agent-translate"]; @@ -125,7 +128,7 @@ export async function translateWithSeer({ }: { apiService: SentryApiService; organizationSlug: string; - projectId: string; + projectId?: string; dataset: SeerSearchDataset; query: string; }): Promise { @@ -136,7 +139,7 @@ export async function translateWithSeer({ const run = await apiService.startSearchAgent({ organizationSlug, - projectIds: [Number(projectId)], + projectIds: [projectId ? Number(projectId) : ALL_ACCESSIBLE_PROJECTS], naturalLanguageQuery: query, strategy: SEER_STRATEGIES[dataset], }); From 129801346260650f8ef0661d0d3c2cfc5db017ec Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 15:15:58 -0400 Subject: [PATCH 08/19] feat(search-events): Search the projects Seer broadens to Seer can broaden a project-scoped search, for example to other services in the same trace, and returns those projects in `project_ids`. Apply them to the events search, validation, and explorer link instead of keeping only the requested project, matching the Sentry UI. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/api-client/client.ts | 38 ++++++------- packages/mcp-core/src/api-client/schema.ts | 3 ++ .../src/tools/catalog/search-events.test.ts | 54 +++++++++++++++++++ .../src/tools/catalog/search-events.ts | 7 +-- .../src/tools/support/search-events/seer.ts | 28 +++++++++- .../src/tools/support/search-events/utils.ts | 18 +++---- packages/mcp-core/src/utils/url-utils.ts | 24 ++++++--- 7 files changed, 127 insertions(+), 45 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index c6756c2ac..ef0193288 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -28,7 +28,9 @@ import { getTraceMetricsExploreUrl, getTraceUrl as getTraceUrlUtil, getUptimeMonitorUrl as getUptimeMonitorUrlUtil, + appendProjectParams, isPublicSentryHost, + type ProjectIdParam, type TraceMetricIdentifier, } from "../utils/url-utils"; import { USER_AGENT } from "../version"; @@ -188,7 +190,7 @@ type ExplorerAggregateParams = { type ExplorerUrlParams = ExplorerAggregateParams & { organizationSlug: string; query: string; - projectId?: string; + projectId?: ProjectIdParam; sort?: string; statsPeriod?: string; start?: string; @@ -1220,7 +1222,7 @@ export class SentryApiService { private buildDiscoverUrl(params: { organizationSlug: string; query: string; - projectId?: string; + projectId?: ProjectIdParam; fields?: string[]; sort?: string; statsPeriod?: string; @@ -1249,9 +1251,7 @@ export class SentryApiService { urlParams.set("queryDataset", "error-events"); urlParams.set("query", query); - if (projectId) { - urlParams.set("project", projectId); - } + appendProjectParams(urlParams, projectId); // Discover API includes aggregate functions directly in field list if (fields && fields.length > 0) { @@ -1387,9 +1387,7 @@ export class SentryApiService { const urlParams = new URLSearchParams(); urlParams.set("query", query); - if (projectId) { - urlParams.set("project", projectId); - } + appendProjectParams(urlParams, projectId); const isAggregateQuery = this.isAggregateExplorerQuery(params); @@ -1423,7 +1421,7 @@ export class SentryApiService { const urlParams = new URLSearchParams(); urlParams.set("logsQuery", query); - urlParams.set("project", projectId ?? "-1"); + appendProjectParams(urlParams, projectId ?? "-1"); const isAggregateQuery = this.isAggregateExplorerQuery(params); if (isAggregateQuery) { @@ -1497,7 +1495,7 @@ export class SentryApiService { getEventsExplorerUrl( organizationSlug: string, query: string, - projectId?: string, + projectId?: ProjectIdParam, dataset: EventsDataset = "spans", fields?: string[], sort?: string, @@ -3672,7 +3670,7 @@ export class SentryApiService { fields?: string[]; query?: string; orderby?: string[]; - project?: string; + project?: ProjectIdParam; environment?: string | string[]; statsPeriod?: string; start?: string; @@ -3682,9 +3680,7 @@ export class SentryApiService { ): Promise { const queryParams = new URLSearchParams(); queryParams.set("dataset", normalizeEventsDataset(dataset)); - if (project) { - queryParams.set("project", project); - } + appendProjectParams(queryParams, project); if (query) { queryParams.set("query", query); } @@ -4700,7 +4696,7 @@ export class SentryApiService { query: string; fields: string[]; limit: number; - projectId?: string; + projectId?: ProjectIdParam; dataset?: "errors" | "tracemetrics" | "profiles"; statsPeriod?: string; start?: string; @@ -4721,9 +4717,7 @@ export class SentryApiService { params.end, ); - if (params.projectId) { - queryParams.set("project", params.projectId); - } + appendProjectParams(queryParams, params.projectId); queryParams.set("sort", params.sort); @@ -4746,7 +4740,7 @@ export class SentryApiService { query: string; fields: string[]; limit: number; - projectId?: string; + projectId?: ProjectIdParam; dataset: "spans" | "logs"; statsPeriod?: string; start?: string; @@ -4767,9 +4761,7 @@ export class SentryApiService { params.end, ); - if (params.projectId) { - queryParams.set("project", params.projectId); - } + appendProjectParams(queryParams, params.projectId); // Dataset-specific parameters if (params.dataset === "spans") { @@ -4813,7 +4805,7 @@ export class SentryApiService { query: string; fields: string[]; limit?: number; - projectId?: string; + projectId?: ProjectIdParam; dataset?: EventsDataset; statsPeriod?: string; start?: string; diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index ad88426c0..f82c1dc6e 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -1452,6 +1452,9 @@ export const SearchAgentTranslateSchema = z .object({ responses: z.array(SearchAgentQuerySchema), unsupported_reason: z.string().nullable().optional(), + // Projects Seer scoped the query to, a superset of the requested projects + // when it broadens scope. Absent when there's no expansion. + project_ids: z.array(z.number()).nullable().optional(), }) .passthrough(); 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 7605230af..05ae5bad1 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3456,6 +3456,60 @@ describe("search_events", () => { expect(result).toContain("Translated by Seer's search agent."); }); + it("should search the projects Seer broadened the query to", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [seerQuery], + unsupported_reason: null, + project_ids: [42, 43], + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll("project")).toEqual(["42", "43"]); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(result).toContain("Seer broadened the search to 2 projects."); + }); + + it("should keep the requested project when Seer does not broaden it", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [seerQuery], + unsupported_reason: null, + project_ids: [42], + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll("project")).toEqual(["42"]); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(result).not.toContain("Seer broadened the search"); + }); + it("should search all accessible projects without a projectSlug", async () => { const mockAllProjectsStart = vi.fn( async ({ request }: { request: Request }) => { diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 2b1419ab9..3c7364e93 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -852,6 +852,7 @@ export default defineTool({ // // Note: fields and sortParam use the same function syntax sent to the API. fields = augmentFieldsWithSort(fields, sortParam); + const eventsProjectId = seerTranslation?.projectIds ?? projectId; const requestFields = buildRequestFields(dataset, fields); @@ -868,7 +869,7 @@ export default defineTool({ fields: requestFields, query: sentryQuery, sort: sortParam, - projectId, + projectId: eventsProjectId, environment: environment ?? undefined, ...timeParams, }); @@ -898,7 +899,7 @@ export default defineTool({ query: sentryQuery, fields: finalRequestFields, limit: params.limit, - projectId, + projectId: eventsProjectId, dataset, sort: sortParam, ...timeParams, @@ -946,7 +947,7 @@ export default defineTool({ : apiService.getEventsExplorerUrl( organizationSlug, sentryQuery, - projectId, + eventsProjectId, dataset, fields, sortParam, diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index 4df066a7a..3ee8e5d19 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -41,6 +41,8 @@ export interface SeerSearchTranslation { fields: string[]; sort: string; timeParams: { statsPeriod?: string; start?: string; end?: string }; + // Set only when Seer broadened the search beyond the requested project. + projectIds?: string[]; explanation: string; } @@ -63,6 +65,7 @@ async function hasSeerSearchAgentAccess( function toSearchTranslation( result: z.output, dataset: SeerSearchDataset, + expandedProjectIds: number[] | undefined, ): SeerSearchTranslation { const aggregates = result.visualization.flatMap((chart) => chart.y_axes); const fields = @@ -109,7 +112,18 @@ function toSearchTranslation( explanation += ` Seer also suggested cross-event filters (${crossEventQueries.join(", ")}), which search_events does not apply.`; } - return { query: result.query, fields, sort, timeParams, explanation }; + if (expandedProjectIds) { + explanation += ` Seer broadened the search to ${expandedProjectIds.length} projects.`; + } + + return { + query: result.query, + fields, + sort, + timeParams, + projectIds: expandedProjectIds?.map(String), + explanation, + }; } /** @@ -153,7 +167,17 @@ export async function translateWithSeer({ if (session?.status === "completed") { const result = session.final_response?.responses[0]; - return result ? toSearchTranslation(result, dataset) : null; + if (!result) { + return null; + } + // Seer can broaden a project-scoped search, e.g. to other services in + // the same trace. With all projects requested there is nothing to add. + const returnedProjectIds = session.final_response?.project_ids ?? []; + const expandedProjectIds = + projectId && returnedProjectIds.some((id) => id !== Number(projectId)) + ? returnedProjectIds + : undefined; + return toSearchTranslation(result, dataset, expandedProjectIds); } if (session?.status === "error") { return null; diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index 79028959a..52964ae6c 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -21,6 +21,7 @@ import { normalizeEventsDataset, PUBLIC_EVENTS_DATASETS, } from "../../../utils/events-datasets"; +import type { ProjectIdParam } from "../../../utils/url-utils"; // Type for flexible event data that can contain any fields export type FlexibleEventData = Record; @@ -177,7 +178,10 @@ function normalizeFilterValue(rawValue: string): string { * Read one filter value starting at `valueStart` in the original query. * Quoted values keep interior whitespace; unquoted values stop at whitespace. */ -function readRawFilterValue(query: string, valueStart: number): string | undefined { +function readRawFilterValue( + query: string, + valueStart: number, +): string | undefined { if (valueStart >= query.length) { return undefined; } @@ -241,9 +245,7 @@ function searchFilterOccurrences(query: string): SearchFilterOccurrence[] { return occurrences; } -function structuredFilterOccurrences( - query: string, -): SearchFilterOccurrence[] { +function structuredFilterOccurrences(query: string): SearchFilterOccurrence[] { return searchFilterOccurrences(query).filter( (occurrence) => !FULL_TEXT_SEARCH_KEYS.has(occurrence.key), ); @@ -284,9 +286,7 @@ function containsAsWholeToken(haystack: string, needle: string): boolean { } function isRelatedFilterValue(left: string, right: string): boolean { - return ( - containsAsWholeToken(left, right) || containsAsWholeToken(right, left) - ); + return containsAsWholeToken(left, right) || containsAsWholeToken(right, left); } /** @@ -870,7 +870,7 @@ export async function validateEventsSearch( fields: string[]; query: string; sort: string; - projectId?: string; + projectId?: ProjectIdParam; environment?: string | string[]; statsPeriod?: string; start?: string; @@ -910,7 +910,7 @@ export async function assertEventsSearchIsValid( fields: string[]; query: string; sort: string; - projectId?: string; + projectId?: ProjectIdParam; environment?: string | string[]; statsPeriod?: string; start?: string; diff --git a/packages/mcp-core/src/utils/url-utils.ts b/packages/mcp-core/src/utils/url-utils.ts index c79401437..cc7c88c3e 100644 --- a/packages/mcp-core/src/utils/url-utils.ts +++ b/packages/mcp-core/src/utils/url-utils.ts @@ -17,6 +17,18 @@ export function isPublicSentryHost(host: string): boolean { return isSentryHost(host) && !host.endsWith(".my.sentry.io"); } +/** One project ID, or several when a search spans multiple projects. */ +export type ProjectIdParam = string | string[]; + +export function appendProjectParams( + params: URLSearchParams, + projectId: ProjectIdParam | undefined, +): void { + for (const id of projectId === undefined ? [] : [projectId].flat()) { + params.append("project", id); + } +} + export interface TraceMetricIdentifier { name: string; type: string; @@ -25,7 +37,7 @@ export interface TraceMetricIdentifier { export interface TraceMetricsExplorerUrlOptions { query: string; - projectId?: string; + projectId?: ProjectIdParam; statsPeriod?: string; start?: string; end?: string; @@ -37,7 +49,7 @@ export interface TraceMetricsExplorerUrlOptions { export interface ProfilesExplorerUrlOptions { query: string; - projectId?: string; + projectId?: ProjectIdParam; statsPeriod?: string; start?: string; end?: string; @@ -195,9 +207,7 @@ export function getTraceMetricsExploreUrl( const urlParams = new URLSearchParams(); - if (projectId) { - urlParams.set("project", projectId); - } + appendProjectParams(urlParams, projectId); if (start && end) { urlParams.set("start", start); @@ -305,9 +315,7 @@ export function getProfilingExplorerUrl( if (query) { urlParams.set("query", query); } - if (projectId) { - urlParams.set("project", projectId); - } + appendProjectParams(urlParams, projectId); if (sort) { urlParams.set("sort", sort); } From 65c399c022ebb6274f0bc275b70b8d1333dddf84 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 15:19:21 -0400 Subject: [PATCH 09/19] ref(search-events): Drop Seer details from the dataset description The calling model can't check whether an organization has Seer enabled, so just ask it to always pass a dataset, which is what enables the Seer path. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/toolDefinitions.json | 2 +- packages/mcp-core/src/tools/catalog/search-events.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index a96bea830..4810b5bf7 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7034,7 +7034,7 @@ "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." }, "dataset": { - "description": "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured. Pass it so Seer can translate natural language queries when the organization has Seer enabled.", + "description": "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.", "type": "string", "enum": ["spans", "errors", "logs", "metrics", "profiles", "replays"] }, diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 3c7364e93..da253674b 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -421,7 +421,7 @@ export default defineTool({ .enum(SEARCH_EVENTS_DATASETS) .optional() .describe( - "Initial dataset hint: errors, logs, spans, metrics, profiles, or replays. The agent may correct this when configured. Pass it so Seer can translate natural language queries when the organization has Seer enabled.", + "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() From 74d00602cf77b852d9121caa2403d043a7ca13d6 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 15:29:39 -0400 Subject: [PATCH 10/19] feat(search-events): Use Seer when an environment is passed The search agent has no environment input, and the Sentry UI applies the environment as a filter after translation. Do the same by letting the existing environment filter apply to Seer's query, and only reject an environment without an agent provider once Seer has also declined. Co-Authored-By: Claude Opus 5.5 --- .../src/tools/catalog/search-events.test.ts | 32 +++++++++++++++++++ .../src/tools/catalog/search-events.ts | 27 ++++++++-------- 2 files changed, 46 insertions(+), 13 deletions(-) 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 05ae5bad1..52acd6079 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3456,6 +3456,38 @@ describe("search_events", () => { expect(result).toContain("Translated by Seer's search agent."); }); + it("should add an explicit environment to Seer's query", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { responses: [seerQuery], unsupported_reason: null }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/environments/", + () => HttpResponse.json([{ id: "1", name: "production" }]), + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("query")).toBe( + "span.op:http.client environment:production", + ); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchEvents.handler( + { ...seerParams, environment: "production" }, + context, + ); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + it("should search the projects Seer broadened the query to", async () => { mswServer.use( mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index da253674b..301ecf58f 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -513,17 +513,6 @@ export default defineTool({ isTraceItemDataset(inputDataset) && hasStructuredQuery; - if ( - !hasAgentProvider() && - inputDataset !== "replays" && - params.environment && - !canApplyEnvironmentFilter - ) { - throw new UserInputError( - "The `environment` parameter is only supported for dataset='replays'. For other datasets, include environment filtering in the query string instead.", - ); - } - let projectId: string | undefined; if (params.projectSlug) { const project = await apiService.getProject({ @@ -565,11 +554,11 @@ export default defineTool({ // 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. + // preserves. Like the UI, an explicit environment is added to Seer's query + // afterwards. const seerTranslation = params.query && isSeerSearchDataset(params.dataset) && - !params.environment && !hasStructuredQuery && !hasExplicitFields && !hasExplicitSort @@ -582,6 +571,18 @@ export default defineTool({ }) : null; + 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 = From 822e6dc27fb696f110c3a3638b7d7db3ff8a8bb8 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 16:01:10 -0400 Subject: [PATCH 11/19] feat(search-events): Return a time series when Seer sets an interval Seer always returns a chart for aggregate queries, since Explore shows one, but only sets an interval when the user asks for time buckets such as "per day". Use that to run the time series request for ungrouped queries instead of collapsing them to a single total. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/api-client/client.ts | 6 +- packages/mcp-core/src/api-client/schema.ts | 2 + .../src/tools/catalog/search-events.test.ts | 78 +++++++++++++++++++ .../src/tools/catalog/search-events.ts | 8 +- .../src/tools/support/search-events/seer.ts | 19 +++++ 5 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index ef0193288..715b91a53 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -4878,7 +4878,7 @@ export class SentryApiService { query: string; yAxis: string; interval?: string; - projectId?: string; + projectId?: ProjectIdParam; dataset?: EventsDataset; statsPeriod?: string; start?: string; @@ -4895,9 +4895,7 @@ export class SentryApiService { queryParams.set("interval", interval); } this.applyTimeParams(queryParams, statsPeriod, start, end); - if (projectId) { - queryParams.set("project", projectId); - } + appendProjectParams(queryParams, projectId); // partial=1 keeps the current (in-progress) bucket, matching Sentry's charts. queryParams.set("partial", "1"); queryParams.set("referrer", SENTRY_MCP_SEARCH_EVENTS_REFERRER); diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index f82c1dc6e..1ec6fa8af 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -1431,6 +1431,8 @@ export const SearchAgentQuerySchema = z z .object({ y_axes: z.array(z.string()).default([]), + // Only set when the user asks for a time bucket, e.g. "per hour". + interval: z.string().nullable().optional(), }) .passthrough(), ) 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 52acd6079..db7300f31 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3456,6 +3456,84 @@ describe("search_events", () => { expect(result).toContain("Translated by Seer's search agent."); }); + it("should return a time series when Seer sets an interval", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + query: "", + group_by: [], + visualization: [ + { chart_type: 1, y_axes: ["count()"], interval: "1d" }, + ], + sort: "-count()", + stats_period: "7d", + }, + ], + unsupported_reason: null, + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("yAxis")).toBe("count()"); + expect(url.searchParams.get("interval")).toBe("1d"); + expect(url.searchParams.get("dataset")).toBe("spans"); + expect(url.searchParams.get("statsPeriod")).toBe("7d"); + return HttpResponse.json({ + data: [ + [1757548800, [{ count: 5 }]], + [1757635200, [{ count: 8 }]], + ], + }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("## count() over time"); + expect(result).toContain("- **Total**: 13"); + }); + + it("should keep a grouped Seer query with an interval as a table", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + visualization: [ + { + chart_type: 1, + y_axes: ["p95(span.duration)"], + interval: "1h", + }, + ], + }, + ], + unsupported_reason: null, + }, + }), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(result).not.toContain("over time"); + }); + it("should add an explicit environment to Seer's query", async () => { mswServer.use( mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 301ecf58f..fc085516b 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -610,6 +610,7 @@ export default defineTool({ ? { statsPeriod: params.period } : seerTranslation.timeParams; explanation = seerTranslation.explanation; + timeSeries = seerTranslation.timeSeries; } else if (willRunAgent) { const parsed = await withProviderFallback({ operation: "search_events.rewrite", @@ -795,6 +796,8 @@ export default defineTool({ return withEnvironmentNote(replayOutput); } + const eventsProjectId = seerTranslation?.projectIds ?? projectId; + if (timeSeries) { const timeSeriesQuery = applyEnvironmentToEventsQuery( dataset, @@ -810,14 +813,14 @@ export default defineTool({ query: timeSeriesQuery, yAxis: timeSeries.yAxis, interval: timeSeries.interval ?? undefined, - projectId, + projectId: eventsProjectId, dataset, ...timeParams, }); const statsUrl = apiService.getEventsExplorerUrl( organizationSlug, timeSeriesQuery, - projectId, + eventsProjectId, dataset, [timeSeries.yAxis], `-${timeSeries.yAxis}`, @@ -853,7 +856,6 @@ export default defineTool({ // // Note: fields and sortParam use the same function syntax sent to the API. fields = augmentFieldsWithSort(fields, sortParam); - const eventsProjectId = seerTranslation?.projectIds ?? projectId; const requestFields = buildRequestFields(dataset, fields); diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index 3ee8e5d19..f4cc9123b 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -41,6 +41,7 @@ export interface SeerSearchTranslation { fields: string[]; sort: string; timeParams: { statsPeriod?: string; start?: string; end?: string }; + timeSeries: { yAxis: string; interval: string } | null; // Set only when Seer broadened the search beyond the requested project. projectIds?: string[]; explanation: string; @@ -93,6 +94,23 @@ function toSearchTranslation( sort = defaultSort; } + // Seer always returns a chart for aggregates since Explore shows one, but only + // sets an interval when the user asks for time buckets, e.g. "per day". The + // time series endpoint can't group, so grouped queries stay a table. + const chartWithInterval = result.visualization.find( + (chart) => chart.interval && chart.y_axes[0], + ); + const timeSeries = + result.mode === "aggregates" && + result.group_by.length === 0 && + chartWithInterval?.interval && + chartWithInterval.y_axes[0] + ? { + yAxis: chartWithInterval.y_axes[0], + interval: chartWithInterval.interval, + } + : null; + let timeParams: SeerSearchTranslation["timeParams"]; if (result.stats_period) { timeParams = { statsPeriod: result.stats_period }; @@ -121,6 +139,7 @@ function toSearchTranslation( fields, sort, timeParams, + timeSeries, projectIds: expandedProjectIds?.map(String), explanation, }; From fd87ae72f7801f3ac514b65193fcac2b7c0171e7 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 16:05:57 -0400 Subject: [PATCH 12/19] fix(search-events): Suggest Seer's wider project scope instead of applying it In the UI, Seer's broader project scope is a suggestion the user accepts. Here the caller chose the project, or the session is scoped to it, so applying it silently overrode that choice and could escape a project constraint. Keep the requested project and note the suggested project IDs instead, except in project-scoped sessions. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/api-client/client.ts | 44 +++++++----- .../src/tools/catalog/search-events.test.ts | 68 ++++++++++++------- .../src/tools/catalog/search-events.ts | 42 +++++++----- .../src/tools/support/search-events/seer.ts | 29 ++++---- .../src/tools/support/search-events/utils.ts | 5 +- packages/mcp-core/src/utils/url-utils.ts | 24 +++---- 6 files changed, 119 insertions(+), 93 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 715b91a53..c6756c2ac 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -28,9 +28,7 @@ import { getTraceMetricsExploreUrl, getTraceUrl as getTraceUrlUtil, getUptimeMonitorUrl as getUptimeMonitorUrlUtil, - appendProjectParams, isPublicSentryHost, - type ProjectIdParam, type TraceMetricIdentifier, } from "../utils/url-utils"; import { USER_AGENT } from "../version"; @@ -190,7 +188,7 @@ type ExplorerAggregateParams = { type ExplorerUrlParams = ExplorerAggregateParams & { organizationSlug: string; query: string; - projectId?: ProjectIdParam; + projectId?: string; sort?: string; statsPeriod?: string; start?: string; @@ -1222,7 +1220,7 @@ export class SentryApiService { private buildDiscoverUrl(params: { organizationSlug: string; query: string; - projectId?: ProjectIdParam; + projectId?: string; fields?: string[]; sort?: string; statsPeriod?: string; @@ -1251,7 +1249,9 @@ export class SentryApiService { urlParams.set("queryDataset", "error-events"); urlParams.set("query", query); - appendProjectParams(urlParams, projectId); + if (projectId) { + urlParams.set("project", projectId); + } // Discover API includes aggregate functions directly in field list if (fields && fields.length > 0) { @@ -1387,7 +1387,9 @@ export class SentryApiService { const urlParams = new URLSearchParams(); urlParams.set("query", query); - appendProjectParams(urlParams, projectId); + if (projectId) { + urlParams.set("project", projectId); + } const isAggregateQuery = this.isAggregateExplorerQuery(params); @@ -1421,7 +1423,7 @@ export class SentryApiService { const urlParams = new URLSearchParams(); urlParams.set("logsQuery", query); - appendProjectParams(urlParams, projectId ?? "-1"); + urlParams.set("project", projectId ?? "-1"); const isAggregateQuery = this.isAggregateExplorerQuery(params); if (isAggregateQuery) { @@ -1495,7 +1497,7 @@ export class SentryApiService { getEventsExplorerUrl( organizationSlug: string, query: string, - projectId?: ProjectIdParam, + projectId?: string, dataset: EventsDataset = "spans", fields?: string[], sort?: string, @@ -3670,7 +3672,7 @@ export class SentryApiService { fields?: string[]; query?: string; orderby?: string[]; - project?: ProjectIdParam; + project?: string; environment?: string | string[]; statsPeriod?: string; start?: string; @@ -3680,7 +3682,9 @@ export class SentryApiService { ): Promise { const queryParams = new URLSearchParams(); queryParams.set("dataset", normalizeEventsDataset(dataset)); - appendProjectParams(queryParams, project); + if (project) { + queryParams.set("project", project); + } if (query) { queryParams.set("query", query); } @@ -4696,7 +4700,7 @@ export class SentryApiService { query: string; fields: string[]; limit: number; - projectId?: ProjectIdParam; + projectId?: string; dataset?: "errors" | "tracemetrics" | "profiles"; statsPeriod?: string; start?: string; @@ -4717,7 +4721,9 @@ export class SentryApiService { params.end, ); - appendProjectParams(queryParams, params.projectId); + if (params.projectId) { + queryParams.set("project", params.projectId); + } queryParams.set("sort", params.sort); @@ -4740,7 +4746,7 @@ export class SentryApiService { query: string; fields: string[]; limit: number; - projectId?: ProjectIdParam; + projectId?: string; dataset: "spans" | "logs"; statsPeriod?: string; start?: string; @@ -4761,7 +4767,9 @@ export class SentryApiService { params.end, ); - appendProjectParams(queryParams, params.projectId); + if (params.projectId) { + queryParams.set("project", params.projectId); + } // Dataset-specific parameters if (params.dataset === "spans") { @@ -4805,7 +4813,7 @@ export class SentryApiService { query: string; fields: string[]; limit?: number; - projectId?: ProjectIdParam; + projectId?: string; dataset?: EventsDataset; statsPeriod?: string; start?: string; @@ -4878,7 +4886,7 @@ export class SentryApiService { query: string; yAxis: string; interval?: string; - projectId?: ProjectIdParam; + projectId?: string; dataset?: EventsDataset; statsPeriod?: string; start?: string; @@ -4895,7 +4903,9 @@ export class SentryApiService { queryParams.set("interval", interval); } this.applyTimeParams(queryParams, statsPeriod, start, end); - appendProjectParams(queryParams, projectId); + if (projectId) { + queryParams.set("project", projectId); + } // partial=1 keeps the current (in-progress) bucket, matching Sentry's charts. queryParams.set("partial", "1"); queryParams.set("referrer", SENTRY_MCP_SEARCH_EVENTS_REFERRER); 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 db7300f31..a5f5520f5 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3566,32 +3566,50 @@ describe("search_events", () => { expect(mockGenerateText).not.toHaveBeenCalled(); }); - it("should search the projects Seer broadened the query to", async () => { - mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), - mockSeerState({ - status: "completed", - final_response: { - responses: [seerQuery], - unsupported_reason: null, - project_ids: [42, 43], - }, - }), - http.get( - "https://sentry.io/api/0/organizations/test-org/events/", - ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.getAll("project")).toEqual(["42", "43"]); - return HttpResponse.json({ data: [] }); - }, - ), - ); + it.each([ + ["suggest", context, true], + [ + "not suggest in a project-scoped session", + { + ...context, + constraints: { ...context.constraints, projectSlug: "test-project" }, + }, + false, + ], + ])( + "should keep the requested project and %s Seer's wider scope", + async (_, handlerContext, expectNote) => { + mswServer.use( + mockOrganization([ + "gen-ai-features", + "gen-ai-search-agent-translate", + ]), + mockSeerState({ + status: "completed", + final_response: { + responses: [seerQuery], + unsupported_reason: null, + project_ids: [42, 43], + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll("project")).toEqual(["42"]); + return HttpResponse.json({ data: [] }); + }, + ), + ); - const result = await searchEvents.handler(seerParams, context); + const result = await searchEvents.handler(seerParams, handlerContext); - expect(mockSeerStart).toHaveBeenCalled(); - expect(result).toContain("Seer broadened the search to 2 projects."); - }); + expect(mockSeerStart).toHaveBeenCalled(); + expect( + result.includes("Seer suggested also searching project IDs 43"), + ).toBe(expectNote); + }, + ); it("should keep the requested project when Seer does not broaden it", async () => { mswServer.use( @@ -3617,7 +3635,7 @@ describe("search_events", () => { const result = await searchEvents.handler(seerParams, context); expect(mockSeerStart).toHaveBeenCalled(); - expect(result).not.toContain("Seer broadened the search"); + expect(result).not.toContain("Seer suggested also searching"); }); it("should search all accessible projects without a projectSlug", async () => { diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index fc085516b..7e9507c29 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -723,8 +723,20 @@ export default defineTool({ unknownEnvironments.length > 0 ? formatUnknownEnvironmentNote(unknownEnvironments, environmentNames) : ""; - const withEnvironmentNote = (text: string): string => - environmentNote ? `${environmentNote}\n\n${text}` : text; + // 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 = [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; @@ -793,11 +805,9 @@ export default defineTool({ availableToolNames: context.availableToolNames, directToolNames: context.directToolNames, }); - return withEnvironmentNote(replayOutput); + return withLeadingNote(replayOutput); } - const eventsProjectId = seerTranslation?.projectIds ?? projectId; - if (timeSeries) { const timeSeriesQuery = applyEnvironmentToEventsQuery( dataset, @@ -813,14 +823,14 @@ export default defineTool({ query: timeSeriesQuery, yAxis: timeSeries.yAxis, interval: timeSeries.interval ?? undefined, - projectId: eventsProjectId, + projectId, dataset, ...timeParams, }); const statsUrl = apiService.getEventsExplorerUrl( organizationSlug, timeSeriesQuery, - eventsProjectId, + projectId, dataset, [timeSeries.yAxis], `-${timeSeries.yAxis}`, @@ -830,7 +840,7 @@ export default defineTool({ timeParams.start, timeParams.end, ); - return withEnvironmentNote( + return withLeadingNote( formatTimeSeriesResults({ series, yAxis: timeSeries.yAxis, @@ -872,7 +882,7 @@ export default defineTool({ fields: requestFields, query: sentryQuery, sort: sortParam, - projectId: eventsProjectId, + projectId, environment: environment ?? undefined, ...timeParams, }); @@ -902,7 +912,7 @@ export default defineTool({ query: sentryQuery, fields: finalRequestFields, limit: params.limit, - projectId: eventsProjectId, + projectId, dataset, sort: sortParam, ...timeParams, @@ -950,7 +960,7 @@ export default defineTool({ : apiService.getEventsExplorerUrl( organizationSlug, sentryQuery, - eventsProjectId, + projectId, dataset, fields, sortParam, @@ -986,15 +996,15 @@ export default defineTool({ switch (dataset) { case "errors": - return withEnvironmentNote(formatErrorResults(formatParams)); + return withLeadingNote(formatErrorResults(formatParams)); case "logs": - return withEnvironmentNote(formatLogResults(formatParams)); + return withLeadingNote(formatLogResults(formatParams)); case "spans": - return withEnvironmentNote(formatSpanResults(formatParams)); + return withLeadingNote(formatSpanResults(formatParams)); case "profiles": - return withEnvironmentNote(formatProfileResults(formatParams)); + return withLeadingNote(formatProfileResults(formatParams)); default: - return withEnvironmentNote(formatTraceMetricsResults(formatParams)); + return withLeadingNote(formatTraceMetricsResults(formatParams)); } }, }); diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index f4cc9123b..a7f3af81a 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -42,8 +42,8 @@ export interface SeerSearchTranslation { sort: string; timeParams: { statsPeriod?: string; start?: string; end?: string }; timeSeries: { yAxis: string; interval: string } | null; - // Set only when Seer broadened the search beyond the requested project. - projectIds?: string[]; + // Other projects Seer suggested searching beyond the requested one. + suggestedProjectIds: number[]; explanation: string; } @@ -66,7 +66,7 @@ async function hasSeerSearchAgentAccess( function toSearchTranslation( result: z.output, dataset: SeerSearchDataset, - expandedProjectIds: number[] | undefined, + suggestedProjectIds: number[], ): SeerSearchTranslation { const aggregates = result.visualization.flatMap((chart) => chart.y_axes); const fields = @@ -130,17 +130,13 @@ function toSearchTranslation( explanation += ` Seer also suggested cross-event filters (${crossEventQueries.join(", ")}), which search_events does not apply.`; } - if (expandedProjectIds) { - explanation += ` Seer broadened the search to ${expandedProjectIds.length} projects.`; - } - return { query: result.query, fields, sort, timeParams, timeSeries, - projectIds: expandedProjectIds?.map(String), + suggestedProjectIds, explanation, }; } @@ -189,14 +185,15 @@ export async function translateWithSeer({ if (!result) { return null; } - // Seer can broaden a project-scoped search, e.g. to other services in - // the same trace. With all projects requested there is nothing to add. - const returnedProjectIds = session.final_response?.project_ids ?? []; - const expandedProjectIds = - projectId && returnedProjectIds.some((id) => id !== Number(projectId)) - ? returnedProjectIds - : undefined; - return toSearchTranslation(result, dataset, expandedProjectIds); + // Seer can suggest broadening a project-scoped search, e.g. to other + // services in the same trace. With all projects requested there is + // nothing to add. + const suggestedProjectIds = projectId + ? (session.final_response?.project_ids ?? []).filter( + (id) => id !== Number(projectId), + ) + : []; + return toSearchTranslation(result, dataset, suggestedProjectIds); } if (session?.status === "error") { return null; diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index 52964ae6c..c9279604a 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -21,7 +21,6 @@ import { normalizeEventsDataset, PUBLIC_EVENTS_DATASETS, } from "../../../utils/events-datasets"; -import type { ProjectIdParam } from "../../../utils/url-utils"; // Type for flexible event data that can contain any fields export type FlexibleEventData = Record; @@ -870,7 +869,7 @@ export async function validateEventsSearch( fields: string[]; query: string; sort: string; - projectId?: ProjectIdParam; + projectId?: string; environment?: string | string[]; statsPeriod?: string; start?: string; @@ -910,7 +909,7 @@ export async function assertEventsSearchIsValid( fields: string[]; query: string; sort: string; - projectId?: ProjectIdParam; + projectId?: string; environment?: string | string[]; statsPeriod?: string; start?: string; diff --git a/packages/mcp-core/src/utils/url-utils.ts b/packages/mcp-core/src/utils/url-utils.ts index cc7c88c3e..c79401437 100644 --- a/packages/mcp-core/src/utils/url-utils.ts +++ b/packages/mcp-core/src/utils/url-utils.ts @@ -17,18 +17,6 @@ export function isPublicSentryHost(host: string): boolean { return isSentryHost(host) && !host.endsWith(".my.sentry.io"); } -/** One project ID, or several when a search spans multiple projects. */ -export type ProjectIdParam = string | string[]; - -export function appendProjectParams( - params: URLSearchParams, - projectId: ProjectIdParam | undefined, -): void { - for (const id of projectId === undefined ? [] : [projectId].flat()) { - params.append("project", id); - } -} - export interface TraceMetricIdentifier { name: string; type: string; @@ -37,7 +25,7 @@ export interface TraceMetricIdentifier { export interface TraceMetricsExplorerUrlOptions { query: string; - projectId?: ProjectIdParam; + projectId?: string; statsPeriod?: string; start?: string; end?: string; @@ -49,7 +37,7 @@ export interface TraceMetricsExplorerUrlOptions { export interface ProfilesExplorerUrlOptions { query: string; - projectId?: ProjectIdParam; + projectId?: string; statsPeriod?: string; start?: string; end?: string; @@ -207,7 +195,9 @@ export function getTraceMetricsExploreUrl( const urlParams = new URLSearchParams(); - appendProjectParams(urlParams, projectId); + if (projectId) { + urlParams.set("project", projectId); + } if (start && end) { urlParams.set("start", start); @@ -315,7 +305,9 @@ export function getProfilingExplorerUrl( if (query) { urlParams.set("query", query); } - appendProjectParams(urlParams, projectId); + if (projectId) { + urlParams.set("project", projectId); + } if (sort) { urlParams.set("sort", sort); } From a4b026e2e84ac6a4e76acda7e3786c0ffce3225d Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 16:11:36 -0400 Subject: [PATCH 13/19] docs(search-events): Pass dataset in natural language examples The dataset description asks callers to always pass it, but the natural language examples omitted it, so models got conflicting guidance. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/skillDefinitions.json | 6 +++--- packages/mcp-core/src/toolDefinitions.json | 2 +- packages/mcp-core/src/tools/catalog/search-events.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 36db06f26..65e83003b 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -194,7 +194,7 @@ }, { "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', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', 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": "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", "requiredScopes": ["event:read"] }, { @@ -269,7 +269,7 @@ }, { "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', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', 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": "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", "requiredScopes": ["event:read"] }, { @@ -400,7 +400,7 @@ }, { "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', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', 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": "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", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 4810b5bf7..202e6efd4 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7025,7 +7025,7 @@ }, { "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', query='how many errors today')\nsearch_events(organizationSlug='my-org', dataset='errors', fields=['issue', 'count()'], sort='-count()')\nsearch_events(organizationSlug='my-org', 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": "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", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 7e9507c29..b58b5f0ee 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -402,9 +402,9 @@ export default defineTool({ "NOT for grouped issue lists (use search_issues) or app screenshots/images (use get_latest_base_snapshot).", "", "", - "search_events(organizationSlug='my-org', query='how many errors today')", + "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', query='errors per hour last 24h')", + "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')", "", From 24b281bca130cf03b9a9e367456cce7b915489e5 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 16:18:55 -0400 Subject: [PATCH 14/19] chore: Revert unrelated reformatting in search-events utils Co-Authored-By: Claude Opus 5.5 --- .../src/tools/support/search-events/utils.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index c9279604a..79028959a 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -177,10 +177,7 @@ function normalizeFilterValue(rawValue: string): string { * Read one filter value starting at `valueStart` in the original query. * Quoted values keep interior whitespace; unquoted values stop at whitespace. */ -function readRawFilterValue( - query: string, - valueStart: number, -): string | undefined { +function readRawFilterValue(query: string, valueStart: number): string | undefined { if (valueStart >= query.length) { return undefined; } @@ -244,7 +241,9 @@ function searchFilterOccurrences(query: string): SearchFilterOccurrence[] { return occurrences; } -function structuredFilterOccurrences(query: string): SearchFilterOccurrence[] { +function structuredFilterOccurrences( + query: string, +): SearchFilterOccurrence[] { return searchFilterOccurrences(query).filter( (occurrence) => !FULL_TEXT_SEARCH_KEYS.has(occurrence.key), ); @@ -285,7 +284,9 @@ function containsAsWholeToken(haystack: string, needle: string): boolean { } function isRelatedFilterValue(left: string, right: string): boolean { - return containsAsWholeToken(left, right) || containsAsWholeToken(right, left); + return ( + containsAsWholeToken(left, right) || containsAsWholeToken(right, left) + ); } /** From 556b1237bdfa7d818d5df82a43fda9cda62c0b59 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 25 Sep 2026 16:20:58 -0400 Subject: [PATCH 15/19] feat(search-events): Apply Seer's cross-event filters The events endpoint accepts `spanQuery`, `logQuery`, and `metricQuery` for the spans and logs datasets, as the Explore UI sends them. Pass Seer's cross-event filters through instead of only mentioning them. The events-stats endpoint used for time series doesn't read them, so those results note the filters as not applied. Co-Authored-By: Claude Opus 5.5 --- packages/mcp-core/src/api-client/client.ts | 19 +++++ .../src/tools/catalog/search-events.test.ts | 72 +++++++++++++++++++ .../src/tools/catalog/search-events.ts | 1 + .../src/tools/support/search-events/seer.ts | 30 +++++--- 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index c6756c2ac..0c02b7151 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -179,6 +179,16 @@ import type { const SENTRY_MCP_SEARCH_EVENTS_REFERRER = "api.mcp.search-events"; +/** + * Filters on other events in the same trace, e.g. spans whose trace also has a + * matching log. Only the spans and logs datasets support them. + */ +export type CrossEventQueries = { + spanQuery?: string; + logQuery?: string; + metricQuery?: string; +}; + type ExplorerAggregateParams = { fields?: string[]; aggregateFunctions?: string[]; @@ -4752,6 +4762,7 @@ export class SentryApiService { start?: string; end?: string; sort: string; + crossEventQueries?: CrossEventQueries; }): URLSearchParams { const queryParams = new URLSearchParams(); @@ -4776,6 +4787,11 @@ export class SentryApiService { queryParams.set("sampling", "NORMAL"); } + const { spanQuery, logQuery, metricQuery } = params.crossEventQueries ?? {}; + if (spanQuery) queryParams.set("spanQuery", spanQuery); + if (logQuery) queryParams.set("logQuery", logQuery); + if (metricQuery) queryParams.set("metricQuery", metricQuery); + queryParams.set("sort", params.sort); // Add fields @@ -4808,6 +4824,7 @@ export class SentryApiService { start, end, sort = "-timestamp", + crossEventQueries, }: { organizationSlug: string; query: string; @@ -4819,6 +4836,7 @@ export class SentryApiService { start?: string; end?: string; sort?: string; + crossEventQueries?: CrossEventQueries; }, opts?: RequestOptions, ) { @@ -4854,6 +4872,7 @@ export class SentryApiService { start, end, sort, + crossEventQueries, }); } 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 a5f5520f5..e4c4f968b 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3503,6 +3503,78 @@ describe("search_events", () => { expect(result).toContain("- **Total**: 13"); }); + it("should apply Seer's cross-event filters", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + span_query: "span.op:db", + log_query: "severity:error", + }, + ], + 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("spanQuery")).toBe("span.op:db"); + expect(url.searchParams.get("logQuery")).toBe("severity:error"); + expect(url.searchParams.has("metricQuery")).toBe(false); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(result).toContain( + "Only includes results whose trace also has matching spans `span.op:db`, logs `severity:error`.", + ); + }); + + it("should note Seer's cross-event filters for a time series", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + group_by: [], + visualization: [ + { chart_type: 1, y_axes: ["count()"], interval: "1h" }, + ], + sort: "-count()", + log_query: "severity:error", + }, + ], + unsupported_reason: null, + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.has("logQuery")).toBe(false); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + const result = await searchEvents.handler(seerParams, context); + + expect(result).toContain( + "Seer also suggested cross-event filters (logs `severity:error`), which time series results do not apply.", + ); + }); + it("should keep a grouped Seer query with an interval as a table", async () => { mswServer.use( mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index b58b5f0ee..a6c8bfb90 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -915,6 +915,7 @@ export default defineTool({ projectId, dataset, sort: sortParam, + crossEventQueries: seerTranslation?.crossEventQueries, ...timeParams, }); diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index a7f3af81a..77dba68e3 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import type { SentryApiService } from "../../../api-client"; +import type { CrossEventQueries, SentryApiService } from "../../../api-client"; import { ApiAuthenticationError, type SearchAgentQuerySchema, @@ -42,6 +42,7 @@ export interface SeerSearchTranslation { sort: string; timeParams: { statsPeriod?: string; start?: string; end?: string }; timeSeries: { yAxis: string; interval: string } | null; + crossEventQueries: CrossEventQueries; // Other projects Seer suggested searching beyond the requested one. suggestedProjectIds: number[]; explanation: string; @@ -120,14 +121,26 @@ function toSearchTranslation( timeParams = { statsPeriod: "14d" }; } + // Filters on other events in the same trace, only returned for spans. + const crossEventQueries: CrossEventQueries = { + spanQuery: result.span_query || undefined, + logQuery: result.log_query || undefined, + metricQuery: result.metric_query || undefined, + }; + const crossEventFilters = [ + ["spans", crossEventQueries.spanQuery], + ["logs", crossEventQueries.logQuery], + ["metrics", crossEventQueries.metricQuery], + ] + .filter(([, query]) => query) + .map(([type, query]) => `${type} \`${query}\``); + let explanation = "Translated by Seer's search agent."; - const crossEventQueries = [ - result.span_query, - result.log_query, - result.metric_query, - ].filter(Boolean); - if (crossEventQueries.length > 0) { - explanation += ` Seer also suggested cross-event filters (${crossEventQueries.join(", ")}), which search_events does not apply.`; + if (crossEventFilters.length > 0) { + // The time series endpoint doesn't support cross-event filters. + explanation += timeSeries + ? ` Seer also suggested cross-event filters (${crossEventFilters.join(", ")}), which time series results do not apply.` + : ` Only includes results whose trace also has matching ${crossEventFilters.join(", ")}.`; } return { @@ -136,6 +149,7 @@ function toSearchTranslation( sort, timeParams, timeSeries, + crossEventQueries, suggestedProjectIds, explanation, }; From be556c1b50bb9e6063c12c1797bafed64fbbde2c Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:09:50 -0400 Subject: [PATCH 16/19] fix(search-events): align Seer project scope with execution Use all accessible projects for successful org-wide Seer translations across validation, results, and Explorer links. Keep existing scope for fallback and explicit-project searches. Co-Authored-By: Codex CLI Agent --- docs/specs/search-events.md | 1 + .../src/tools/catalog/search-events.test.ts | 105 +++++++++++++++++- .../src/tools/catalog/search-events.ts | 3 + 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 8e88738f6..0b1676f2e 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -107,6 +107,7 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re - **Logs timestamp handling**: Logs don't support query-based timestamp filters like `timestamp:-1h`. Instead, use `statsPeriod=24h` parameter - **Project ID mapping**: API requires numeric project IDs, not slugs. Tool automatically converts project slugs to IDs +- **Seer project scope**: For a successful Seer translation without `projectSlug`, search and Explorer links use `project=-1` to match the all-accessible-project scope sent to Seer. Other unscoped searches retain their existing default scope. - **Parallel attribute fetching**: For spans/logs/metrics, fetches both string and number attribute types in parallel for better performance - **itemType specification**: Must use `logs` and `tracemetrics` exactly for the trace-items attributes API - **Tracemetrics sort handling**: Aggregate sort expressions like `-p95(value,...)` must be sent to the API unchanged 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 e4c4f968b..5d1c03cf6 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3727,15 +3727,81 @@ describe("search_events", () => { status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, }), - http.get("https://sentry.io/api/0/organizations/test-org/events/", () => - HttpResponse.json({ data: [] }), + http.get( + "https://sentry.io/api/0/organizations/test-org/environments/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("project")).toBe("-1"); + return HttpResponse.json([{ id: "1", name: "production" }]); + }, + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("project")).toBe("-1"); + return HttpResponse.json(validEventsValidationResponse); + }, + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("project")).toBe("-1"); + return HttpResponse.json({ data: [] }); + }, + { once: true }, ), ); - await searchEvents.handler({ ...seerParams, projectSlug: null }, context); + const result = await searchEvents.handler( + { ...seerParams, projectSlug: null, environment: "production" }, + context, + ); expect(mockAllProjectsStart).toHaveBeenCalled(); expect(mockGenerateText).not.toHaveBeenCalled(); + expect(result).toContain("project=-1"); + }); + + it("should keep Seer's all-project scope for time series", async () => { + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + async ({ request }) => { + expect(await request.json()).toMatchObject({ project_ids: [-1] }); + return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); + }, + { once: true }, + ), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + group_by: [], + visualization: [{ y_axes: ["count()"], interval: "1d" }], + }, + ], + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("project")).toBe("-1"); + return HttpResponse.json({ data: [] }); + }, + { once: true }, + ), + ); + + const result = await searchEvents.handler( + { ...seerParams, projectSlug: null }, + context, + ); + + expect(result).toContain("project=-1"); }); it("should prefer an explicit period over Seer's time range", async () => { @@ -3816,12 +3882,39 @@ describe("search_events", () => { ); mswServer.use( mockOrganization(["gen-ai-features"]), - http.get("https://sentry.io/api/0/organizations/test-org/events/", () => - HttpResponse.json({ data: [] }), + http.get( + "https://sentry.io/api/0/organizations/test-org/environments/", + ({ request }) => { + expect(new URL(request.url).searchParams.has("project")).toBe( + false, + ); + return HttpResponse.json([]); + }, + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + ({ request }) => { + expect(new URL(request.url).searchParams.has("project")).toBe( + false, + ); + return HttpResponse.json(validEventsValidationResponse); + }, + { once: true }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + expect(new URL(request.url).searchParams.has("project")).toBe( + false, + ); + return HttpResponse.json({ data: [] }); + }, + { once: true }, ), ); - await searchEvents.handler(seerParams, context); + await searchEvents.handler({ ...seerParams, projectSlug: null }, context); expect(mockSeerStart).not.toHaveBeenCalled(); expect(mockGenerateText).toHaveBeenCalled(); diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index a6c8bfb90..f35199c05 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -570,6 +570,9 @@ export default defineTool({ query: params.query, }) : null; + if (seerTranslation && !projectId) { + projectId = "-1"; + } if ( !hasAgentProvider() && From 3017556bec9ef9877f2307ab5d3b5d36fa1850a3 Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:20:16 -0400 Subject: [PATCH 17/19] feat(search-events): gate Seer translation behind experimental mode Keep the embedded-agent translation path as the default. Opt in to Seer with the existing experimental session flag and cover both paths in tests. Co-Authored-By: Codex CLI Agent --- docs/specs/search-events.md | 1 + .../src/tools/catalog/search-events.test.ts | 22 +++++++++++++++++++ .../src/tools/catalog/search-events.ts | 1 + 3 files changed, 24 insertions(+) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 0b1676f2e..21e67702a 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -107,6 +107,7 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re - **Logs timestamp handling**: Logs don't support query-based timestamp filters like `timestamp:-1h`. Instead, use `statsPeriod=24h` parameter - **Project ID mapping**: API requires numeric project IDs, not slugs. Tool automatically converts project slugs to IDs +- **Seer opt-in**: Seer translation runs only in experimental sessions (`--experimental` for stdio or `/mcp?experimental=1` for HTTP), when the organization has the required Seer features. Default sessions use the configured embedded agent for natural-language translation; if Seer is unavailable in an experimental session, the tool falls back to that agent. - **Seer project scope**: For a successful Seer translation without `projectSlug`, search and Explorer links use `project=-1` to match the all-accessible-project scope sent to Seer. Other unscoped searches retain their existing default scope. - **Parallel attribute fetching**: For spans/logs/metrics, fetches both string and number attribute types in parallel for better performance - **itemType specification**: Must use `logs` and `tracemetrics` exactly for the trace-items attributes API 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 5d1c03cf6..959bc4674 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3353,6 +3353,7 @@ describe("search_events", () => { }, accessToken: "test-token", userId: "1", + experimentalMode: true, }; const seerQuery = { query: "span.op:http.client", @@ -3417,6 +3418,27 @@ describe("search_events", () => { ); }); + it("uses the embedded agent without experimental opt-in", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + const result = await searchEvents.handler(seerParams, { + ...context, + experimentalMode: undefined, + }); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + expect(result).not.toContain("Translated by Seer's search agent."); + }); + it("should translate natural language queries with Seer", async () => { mswServer.use( mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index f35199c05..fcb1c55b7 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -557,6 +557,7 @@ export default defineTool({ // preserves. Like the UI, an explicit environment is added to Seer's query // afterwards. const seerTranslation = + context.experimentalMode && params.query && isSeerSearchDataset(params.dataset) && !hasStructuredQuery && From e628801d51945b920c5f77229aceae15310bddfb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:29:08 +0000 Subject: [PATCH 18/19] fix(search-events): Always show omitted cross-event filter warnings Keep correctness warnings separate from optional query explanations and cover default, disabled, and enabled explanations. Co-Authored-By: Shaun Kaasten --- docs/specs/search-events.md | 1 + .../src/tools/catalog/search-events.test.ts | 117 +++++++++++++----- .../src/tools/catalog/search-events.ts | 6 +- .../src/tools/support/search-events/seer.ts | 14 ++- 4 files changed, 100 insertions(+), 38 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 21e67702a..339e690cf 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -108,6 +108,7 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re - **Logs timestamp handling**: Logs don't support query-based timestamp filters like `timestamp:-1h`. Instead, use `statsPeriod=24h` parameter - **Project ID mapping**: API requires numeric project IDs, not slugs. Tool automatically converts project slugs to IDs - **Seer opt-in**: Seer translation runs only in experimental sessions (`--experimental` for stdio or `/mcp?experimental=1` for HTTP), when the organization has the required Seer features. Default sessions use the configured embedded agent for natural-language translation; if Seer is unavailable in an experimental session, the tool falls back to that agent. +- **Seer cross-event filters**: Time series results do not apply cross-event filters. When Seer returns those filters for a time series, the response always begins with a warning identifying the omitted filters and the broader results, even when `includeExplanation` is false. - **Seer project scope**: For a successful Seer translation without `projectSlug`, search and Explorer links use `project=-1` to match the all-accessible-project scope sent to Seer. Other unscoped searches retain their existing default scope. - **Parallel attribute fetching**: For spans/logs/metrics, fetches both string and number attribute types in parallel for better performance - **itemType specification**: Must use `logs` and `tracemetrics` exactly for the trace-items attributes API 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 959bc4674..88e8598f6 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3523,6 +3523,7 @@ describe("search_events", () => { expect(mockGenerateText).not.toHaveBeenCalled(); expect(result).toContain("## count() over time"); expect(result).toContain("- **Total**: 13"); + expect(result).not.toContain("**Warning:**"); }); it("should apply Seer's cross-event filters", async () => { @@ -3558,44 +3559,94 @@ describe("search_events", () => { expect(result).toContain( "Only includes results whose trace also has matching spans `span.op:db`, logs `severity:error`.", ); + expect(result).not.toContain("**Warning:**"); }); - it("should note Seer's cross-event filters for a time series", async () => { - mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), - mockSeerState({ - status: "completed", - final_response: { - responses: [ - { - ...seerQuery, - group_by: [], - visualization: [ - { chart_type: 1, y_axes: ["count()"], interval: "1h" }, - ], - sort: "-count()", - log_query: "severity:error", - }, - ], - unsupported_reason: null, - }, - }), - http.get( - "https://sentry.io/api/0/organizations/test-org/events-stats/", - ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.has("logQuery")).toBe(false); - return HttpResponse.json({ data: [] }); + it.each([false, undefined, true])( + "warns about unapplied time-series filters with includeExplanation=%s", + async (includeExplanation) => { + mswServer.use( + mockOrganization([ + "gen-ai-features", + "gen-ai-search-agent-translate", + ]), + mockSeerState({ + status: "completed", + final_response: { + responses: [ + { + ...seerQuery, + group_by: [], + visualization: [ + { chart_type: 1, y_axes: ["count()"], interval: "1h" }, + ], + sort: "-count()", + span_query: "span.op:db", + log_query: "severity:error", + metric_query: "metric.name:requests", + }, + ], + unsupported_reason: null, + }, + }), + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.has("spanQuery")).toBe(false); + expect(url.searchParams.has("logQuery")).toBe(false); + expect(url.searchParams.has("metricQuery")).toBe(false); + return HttpResponse.json({ + data: [[1757548800, [{ count: 100 }]]], + }); + }, + ), + ); + + const result = await searchEvents.handler( + { + ...seerParams, + includeExplanation: + searchEvents.inputSchema.includeExplanation.parse( + includeExplanation, + ), }, - ), - ); + context, + ); - const result = await searchEvents.handler(seerParams, context); + const warning = + "**Warning:** Time series results are unfiltered by the requested cross-event filters (spans `span.op:db`, logs `severity:error`, metrics `metric.name:requests`). Counts and other values may include events outside the requested subset."; + expect(result.startsWith(`${warning}\n\n`)).toBe(true); + expect(result.split(warning)).toHaveLength(2); + expect(result.includes("Translated by Seer's search agent.")).toBe( + includeExplanation === true, + ); + expect(result).toContain("- **Total**: 100"); + if (includeExplanation === false) { + expect(result).toMatchInlineSnapshot(` + "**Warning:** Time series results are unfiltered by the requested cross-event filters (spans \`span.op:db\`, logs \`severity:error\`, metrics \`metric.name:requests\`). Counts and other values may include events outside the requested subset. - expect(result).toContain( - "Seer also suggested cross-event filters (logs `severity:error`), which time series results do not apply.", - ); - }); + # Search Results for "slowest http requests in the last day" + + ## count() over time + - **Interval**: \`1h\` + - **Time range**: Last 24h + - **Total**: 100 + - **Peak**: 100 at 2025-09-11 00:00 + + ## Buckets + + | Time (UTC) | Value | + | --- | --- | + | 2025-09-11 00:00 | 100 | + + **View these results in Sentry**: + https://test-org.sentry.io/explore/traces/?query=span.op%3Ahttp.client&project=42&aggregateField=%7B%22yAxes%22%3A%5B%22count%28%29%22%5D%7D&mode=aggregate&sort=-count%28%29&statsPeriod=24h&table=span + Please tell the user this dashboard link is available if they want to open the results in Sentry." + `); + } + }, + ); it("should keep a grouped Seer query with an interval as a table", async () => { mswServer.use( diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index fcb1c55b7..180c53d84 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -736,7 +736,11 @@ export default defineTool({ 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 = [environmentNote, projectSuggestionNote] + const leadingNote = [ + seerTranslation?.warning, + environmentNote, + projectSuggestionNote, + ] .filter(Boolean) .join("\n\n"); const withLeadingNote = (text: string): string => diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index 77dba68e3..8845c5bfa 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -46,6 +46,8 @@ export interface SeerSearchTranslation { // Other projects Seer suggested searching beyond the requested one. suggestedProjectIds: number[]; explanation: string; + // Correctness warnings must be shown even when explanations are disabled. + warning?: string; } async function hasSeerSearchAgentAccess( @@ -136,11 +138,14 @@ function toSearchTranslation( .map(([type, query]) => `${type} \`${query}\``); let explanation = "Translated by Seer's search agent."; + let warning: string | undefined; if (crossEventFilters.length > 0) { - // The time series endpoint doesn't support cross-event filters. - explanation += timeSeries - ? ` Seer also suggested cross-event filters (${crossEventFilters.join(", ")}), which time series results do not apply.` - : ` Only includes results whose trace also has matching ${crossEventFilters.join(", ")}.`; + if (timeSeries) { + // The time series endpoint doesn't support cross-event filters. + warning = `**Warning:** Time series results are unfiltered by the requested cross-event filters (${crossEventFilters.join(", ")}). Counts and other values may include events outside the requested subset.`; + } else { + explanation += ` Only includes results whose trace also has matching ${crossEventFilters.join(", ")}.`; + } } return { @@ -152,6 +157,7 @@ function toSearchTranslation( crossEventQueries, suggestedProjectIds, explanation, + warning, }; } From eef8da8ed7e90fa937913f420348e7dc1d46e148 Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:04:47 -0400 Subject: [PATCH 19/19] fix(search-events): remove retired Seer access flag gate Co-Authored-By: Codex CLI Agent --- .../src/tools/catalog/search-events.test.ts | 40 ++++++++----------- .../src/tools/support/search-events/seer.ts | 7 ++-- 2 files changed, 20 insertions(+), 27 deletions(-) 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 7effa0be8..b3cfb3f40 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3430,7 +3430,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), http.get("https://sentry.io/api/0/organizations/test-org/events/", () => HttpResponse.json({ data: [] }), ), @@ -3448,7 +3448,7 @@ describe("search_events", () => { it("should translate natural language queries with Seer", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -3487,7 +3487,7 @@ describe("search_events", () => { it("should return a time series when Seer sets an interval", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3535,7 +3535,7 @@ describe("search_events", () => { it("should apply Seer's cross-event filters", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3573,10 +3573,7 @@ describe("search_events", () => { "warns about unapplied time-series filters with includeExplanation=%s", async (includeExplanation) => { mswServer.use( - mockOrganization([ - "gen-ai-features", - "gen-ai-search-agent-translate", - ]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3657,7 +3654,7 @@ describe("search_events", () => { it("should keep a grouped Seer query with an interval as a table", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3688,7 +3685,7 @@ describe("search_events", () => { it("should add an explicit environment to Seer's query", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -3732,10 +3729,7 @@ describe("search_events", () => { "should keep the requested project and %s Seer's wider scope", async (_, handlerContext, expectNote) => { mswServer.use( - mockOrganization([ - "gen-ai-features", - "gen-ai-search-agent-translate", - ]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3765,7 +3759,7 @@ describe("search_events", () => { it("should keep the requested project when Seer does not broaden it", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3798,7 +3792,7 @@ describe("search_events", () => { }, ); mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", mockAllProjectsStart, @@ -3845,7 +3839,7 @@ describe("search_events", () => { it("should keep Seer's all-project scope for time series", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", async ({ request }) => { @@ -3886,7 +3880,7 @@ describe("search_events", () => { it("should prefer an explicit period over Seer's time range", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -3908,7 +3902,7 @@ describe("search_events", () => { it("should not group by a non-aggregate Seer sort", async () => { mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3944,7 +3938,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), http.get("https://sentry.io/api/0/organizations/test-org/events/", () => HttpResponse.json({ data: [] }), ), @@ -3961,7 +3955,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(["gen-ai-features"]), + mockOrganization([]), http.get( "https://sentry.io/api/0/organizations/test-org/environments/", ({ request }) => { @@ -4005,7 +3999,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), mockSeerState({ status: "error", unsupported_reason: "Unsupported" }), http.get("https://sentry.io/api/0/organizations/test-org/events/", () => HttpResponse.json({ data: [] }), @@ -4023,7 +4017,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(["gen-ai-features", "gen-ai-search-agent-translate"]), + mockOrganization(["gen-ai-search-agent-translate"]), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", () => diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index 8845c5bfa..5ba7408d0 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -17,9 +17,8 @@ export const SEER_SEARCH_AGENT_TIMEOUT = 60 * 1000; // 1 minute // Sentry's sentinel for all projects the user can access. const ALL_ACCESSIBLE_PROJECTS = -1; -// Organization features the search agent endpoints require. `hideAiFeatures` -// is checked separately. -const REQUIRED_FEATURES = ["gen-ai-features", "gen-ai-search-agent-translate"]; +// The search agent endpoints require this feature. `hideAiFeatures` is checked separately. +const SEARCH_AGENT_FEATURE = "gen-ai-search-agent-translate"; const SEER_STRATEGIES = { errors: "Errors", @@ -63,7 +62,7 @@ async function hasSeerSearchAgentAccess( return false; } const features = organization.features ?? []; - return REQUIRED_FEATURES.every((feature) => features.includes(feature)); + return features.includes(SEARCH_AGENT_FEATURE); } function toSearchTranslation(