Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/mcp-core/src/api-client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3875,7 +3875,7 @@ export class SentryApiService {
end?: string;
},
opts?: RequestOptions,
): Promise<EventsValidationResult> {
): Promise<EventsValidationResult | null> {
const queryParams = new URLSearchParams();
queryParams.set("dataset", normalizeEventsDataset(dataset));
if (project) {
Expand All @@ -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);
}
Expand Down
69 changes: 69 additions & 0 deletions packages/mcp-core/src/tools/catalog/search-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
31 changes: 21 additions & 10 deletions packages/mcp-core/src/tools/support/search-events/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion packages/mcp-core/src/tools/support/search-events/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand All @@ -909,7 +917,7 @@ export async function validateEventsSearch(
start?: string;
end?: string;
},
): Promise<EventsValidationResult> {
): Promise<EventsValidationResult | null> {
return apiService.validateEvents({
organizationSlug,
dataset,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading