From c7b4b631dbb58b21eb48f8bc0a094f0cae75e8b4 Mon Sep 17 00:00:00 2001 From: "Mo.Rajabi" Date: Wed, 14 Oct 2026 14:15:26 +0330 Subject: [PATCH 1/2] fix(search-events): skip validation when the validate endpoint is unavailable The events validation endpoint (/events/validate/) was added to Sentry in mid-2026 and is absent on older deployments, including self-hosted instances. The search tools treated the resulting 404 as a fatal error and failed the whole search with a misleading message, even though the underlying /events/ endpoint works. Treat a 404 from the validation endpoint as 'validation unsupported' rather than a bad request: validateEvents now returns null, and callers (runSearchEvents, assertEventsSearchIsValid, and the agent validateSearch tool) skip the gate and run the search directly. Refs getsentry/toolkit#1497 --- .../mcp-core/src/api-client/client.test.ts | 28 ++++++++ packages/mcp-core/src/api-client/client.ts | 12 +++- .../src/tools/catalog/search-events.test.ts | 69 +++++++++++++++++++ .../src/tools/support/search-events/search.ts | 28 +++++--- .../src/tools/support/search-events/utils.ts | 25 ++++++- 5 files changed, 148 insertions(+), 14 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 63d21b7cd..c91337060 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -2412,6 +2412,34 @@ describe("API query builders", () => { expect(params.getAll("environment")).toEqual(["production", "staging"]); expect(requestOptions?.method).toBeUndefined(); }); + + it("returns null when the validate endpoint is unavailable (404)", async () => { + const apiService = new SentryApiService({ + host: "sentry.example.com", + accessToken: "test-token", + }); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + headers: { + get: (key: string) => + key === "content-type" ? "application/json" : null, + }, + json: () => Promise.resolve({ detail: "Not Found" }), + text: () => Promise.resolve(JSON.stringify({ detail: "Not Found" })), + }); + + const result = await apiService.validateEvents({ + organizationSlug: "test-org", + dataset: "spans", + fields: ["span.duration"], + query: "", + orderby: ["-span.duration"], + }); + + expect(result).toBeNull(); + }); }); describe("Web URL builders", () => { diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 40590529b..47989e95a 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -3875,7 +3875,7 @@ export class SentryApiService { end?: string; }, opts?: RequestOptions, - ): Promise { + ): Promise { const queryParams = new URLSearchParams(); queryParams.set("dataset", normalizeEventsDataset(dataset)); if (project) { @@ -3900,12 +3900,20 @@ export class SentryApiService { queryParams.append("orderby", value); } + // The events validation endpoint was added to Sentry in mid-2026 and is + // absent on older deployments (notably self-hosted instances before that + // release). A 404 therefore means "validation is not supported here" + // rather than a bad request, so return `null` and let callers skip the + // validation gate instead of failing the whole search. const response = await this.request( apiPath`/organizations/${organizationSlug}/events/validate/` + `?${queryParams.toString()}`, undefined, - { ...opts, allowStatuses: [400] }, + { ...opts, allowStatuses: [400, 404] }, ); + if (response.status === 404) { + return null; + } const body = await this.parseJsonResponse(response); return EventsValidationResponseSchema.parse(body); } 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 ab030f013..50f4eca69 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -2951,6 +2951,75 @@ describe("search_events", () => { expect(result).toContain("span1"); }); + it("runs the search when the validate endpoint is unavailable (404)", async () => { + let eventsRequestUrl: URL | undefined; + + mockGenerateText.mockResolvedValueOnce( + mockAIResponse( + "errors", + "level:error", + ["issue", "title", "timestamp"], + undefined, + "-timestamp", + { statsPeriod: "24h" }, + ), + ); + + // Simulate an older/self-hosted Sentry that does not expose the + // `/events/validate/` endpoint. + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => HttpResponse.json({ detail: "Not Found" }, { status: 404 }), + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + eventsRequestUrl = new URL(request.url); + return HttpResponse.json({ + data: [ + { + id: "issue1", + title: "Database Error", + level: "error", + timestamp: "2024-01-15T10:30:00Z", + }, + ], + }); + }, + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "level:error", + fields: null, + sort: null, + period: "24h", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ); + + // Validation is skipped, so the search still executes and returns data. + expect(eventsRequestUrl).toBeDefined(); + expect(eventsRequestUrl!.searchParams.get("dataset")).toBe("errors"); + expect(result).toContain("Database Error"); + }); + it("fails complete structured requests honestly without an external repair loop", async () => { let validateCalls = 0; diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 508dd4513..f49ece884 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -1000,18 +1000,24 @@ export async function runSearchEvents( environment: environment ?? undefined, ...timeParams, }); - recordEventsSearchValidationTelemetry({ - attempt: 0, - validation: lastValidation, - }); - if (!lastValidation.valid) { - const formatted = formatEventsValidationResults(lastValidation); - throw new UserInputError( - formatted - ? `Search validation failed:\n${formatted}` - : "Search validation failed.", - ); + // `null` means the target deployment does not expose the validation + // endpoint (e.g. older self-hosted Sentry). Skip the gate and run the + // search directly instead of failing with a misleading 404. + if (lastValidation !== null) { + recordEventsSearchValidationTelemetry({ + attempt: 0, + validation: lastValidation, + }); + + if (!lastValidation.valid) { + const formatted = formatEventsValidationResults(lastValidation); + throw new UserInputError( + formatted + ? `Search validation failed:\n${formatted}` + : "Search validation failed.", + ); + } } const finalRequestFields = buildRequestFields(dataset, fields); 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 d2753b58e..1c421d397 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -884,6 +884,14 @@ export function recordEventsSearchValidationTelemetry({ } } +/** + * Validate a candidate events search against Sentry's validation endpoint. + * + * Returns `null` when the target deployment does not expose the + * `/events/validate/` endpoint (e.g. older self-hosted Sentry instances). A + * `null` result means validation is unavailable and callers should proceed + * with the search rather than treating it as a failure. + */ export async function validateEventsSearch( apiService: SentryApiService, { @@ -909,7 +917,7 @@ export async function validateEventsSearch( start?: string; end?: string; }, -): Promise { +): Promise { return apiService.validateEvents({ organizationSlug, dataset, @@ -963,6 +971,11 @@ export async function assertEventsSearchIsValid( end, }); + // `null` means the deployment does not support validation; skip the gate. + if (validationResults === null) { + return; + } + if (!validationResults.valid) { const formatted = formatEventsValidationResults(validationResults); throw new UserInputError( @@ -1246,6 +1259,16 @@ export function createValidateEventsSearchTool(options: { end, }); + // The deployment does not expose the validation endpoint; treat the + // candidate as valid so the search can proceed. + if (validation === null) { + return { + valid: true, + message: + "Events validation is not supported by this Sentry instance; skipped.", + }; + } + recordEventsSearchValidationTelemetry({ attempt: 0, validation, From f8c6402c38238aa8056eb0ff0fbcc6e19849b12b Mon Sep 17 00:00:00 2001 From: "Mo.Rajabi" Date: Wed, 14 Oct 2026 14:25:50 +0330 Subject: [PATCH 2/2] test(search-events): restore fetch between tests and record skipped validation - Snapshot and restore globalThis.fetch around every test in client.test.ts so the direct fetch mocks used by several describes no longer leak into later tests that rely on MSW. Rewrite the 404 unit test to use MSW. - Record app.search_events.validation.unsupported on the active span when the validate endpoint is unavailable, so the skip is observable rather than silent. --- .../mcp-core/src/api-client/client.test.ts | 29 ++++++++++++------- .../src/tools/support/search-events/search.ts | 7 ++++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index c91337060..ce7052fc0 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -10,6 +10,19 @@ import { parseSentryUrl } from "../internal/url-helpers"; import { SentryApiService } from "./client"; import { ApiNotFoundError, ApiPermissionError, ApiServerError } from "./errors"; +// Many tests in this file replace `globalThis.fetch` directly. Snapshot and +// restore it around every test so a mock never leaks into later tests that +// rely on MSW interception. +let savedFetch: typeof globalThis.fetch; + +beforeEach(() => { + savedFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = savedFetch; +}); + describe("API bearer token validation", () => { it("removes edge padding before sending a request", async () => { let authorization: string | null = null; @@ -2419,16 +2432,12 @@ describe("API query builders", () => { accessToken: "test-token", }); - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 404, - headers: { - get: (key: string) => - key === "content-type" ? "application/json" : null, - }, - json: () => Promise.resolve({ detail: "Not Found" }), - text: () => Promise.resolve(JSON.stringify({ detail: "Not Found" })), - }); + mswServer.use( + http.get( + "https://sentry.example.com/api/0/organizations/test-org/events/validate/", + () => HttpResponse.json({ detail: "Not Found" }, { status: 404 }), + ), + ); const result = await apiService.validateEvents({ organizationSlug: "test-org", diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index f49ece884..157dfb17d 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -1004,7 +1004,12 @@ export async function runSearchEvents( // `null` means the target deployment does not expose the validation // endpoint (e.g. older self-hosted Sentry). Skip the gate and run the // search directly instead of failing with a misleading 404. - if (lastValidation !== null) { + if (lastValidation === null) { + getActiveSpan()?.setAttribute( + "app.search_events.validation.unsupported", + true, + ); + } else { recordEventsSearchValidationTelemetry({ attempt: 0, validation: lastValidation,