diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 63d21b7cd..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; @@ -2412,6 +2425,30 @@ 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", + }); + + 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", + 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..157dfb17d 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,29 @@ 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) { + getActiveSpan()?.setAttribute( + "app.search_events.validation.unsupported", + true, ); + } else { + 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,