diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 874b9f40d..dfdc4a550 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -10,6 +10,7 @@ import { ConfigurationError } from "../errors"; import { parseSentryUrl } from "../internal/url-helpers"; import { SentryApiService } from "./client"; import { + ApiClientError, ApiNotFoundError, ApiPermissionError, ApiRateLimitError, @@ -108,6 +109,71 @@ describe("validateEvents error bodies", () => { ); }); +describe("validateEvents non-JSON responses", () => { + afterEach(() => mswServer.resetHandlers()); + + it("classifies the endpoint's empty feature-gate 400 as a client error", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => new HttpResponse(null, { status: 400 }), + ), + ); + const api = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + await expect( + api.validateEvents({ organizationSlug: "test-org" }), + ).rejects.toBeInstanceOf(ApiValidationError); + }); + + it.each(["text/plain", "text/html"])( + "classifies a 400 %s response as a client error", + async (contentType) => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => + new HttpResponse("Invalid query", { + status: 400, + headers: { "Content-Type": contentType }, + }), + ), + ); + const api = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + await expect( + api.validateEvents({ organizationSlug: "test-org" }), + ).rejects.toBeInstanceOf(ApiValidationError); + }, + ); + + it("does not classify a successful HTML response as user input", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => + new HttpResponse("Unexpected response", { + status: 200, + headers: { "Content-Type": "text/html" }, + }), + ), + ); + const api = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + const result = api.validateEvents({ organizationSlug: "test-org" }); + await expect(result).rejects.toThrow( + "Expected JSON response but received HTML", + ); + await expect(result).rejects.not.toBeInstanceOf(ApiClientError); + }); +}); + describe("API bearer token validation", () => { it("removes edge padding before sending a request", async () => { let authorization: string | null = null; diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 7b6ae29c1..21f3e9368 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -3634,6 +3634,19 @@ export class SentryApiService { undefined, { ...opts, allowStatuses: [400] }, ); + // The endpoint can reject with an empty or non-JSON 400, such as when its + // feature gate is unavailable. That is still a client error, not a server bug. + const contentType = response.headers.get("content-type"); + if (response.status === 400 && !contentType?.includes("application/json")) { + const text = await response.text(); + throw createApiError( + `Expected JSON response but received ${contentType || "unknown content type"} ` + + `(${response.status} ${response.statusText})`, + response.status, + undefined, + text, + ); + } const body = await readJsonResponse(response); // ParseError responses use a detail envelope instead of validation results. // Keep malformed validation results reportable, even when their status is 400.